JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
boxes don't click/hover as if they're filled
[crayon_mockup.git] / main.coffee
index 270b3cd..8f20313 100644 (file)
@@ -20,24 +20,17 @@ height = 600
 supply_height = 96
 CLICK_FUZ = 10 # this far away from things is close enough to be "clicked on"
 PROX_MAX = CLICK_FUZ * CLICK_FUZ
+PROX_TOO_FAR = PROX_MAX + 1 # no need to be precice when it's too far
 
 # constants
-STYLE_NORMAL = 0
-STYLE_SELECTED = 1
-STYLE_HOVER = 2
-STYLE_EDITING = 3
-STYLE_DRAGGING = 4
-
-STYLE_TO_CLASS = [
-       "normal"
-       "selected"
-       "hover"
-       "editing"
-       "dragging"
-]
-
-set_style_class = (args) ->
-       args.el.setAttribute 'class', "#{args.class} #{STYLE_TO_CLASS[args.style]}"
+STATES = {
+       NORMAL:   { txt: 'normal' }
+       SELECTED: { txt: 'selected' }
+       DRAGGING: { txt: 'dragging' }
+       EDITING:  { txt: 'editing' }
+}
+TYPE_WIDGET = 1
+TYPE_CONTROL = 2
 
 # json (compiled to javascript and minified) is ~8% smaller than the resulting xml
 json_to_svg = (json) ->
@@ -53,6 +46,53 @@ json_to_svg = (json) ->
                                el.setAttribute k, v
        return el
 
+resizer_nw = (widget) ->
+       return (dxy) ->
+               widget.resize w: widget.width - dxy.x, h: widget.height - dxy.y
+               widget.move x: widget.x + dxy.x, y: widget.y + dxy.y
+resizer_n = (widget) ->
+       return (dxy) ->
+               widget.resize w: widget.width, h: widget.height - dxy.y
+               widget.move x: widget.x, y: widget.y + dxy.y
+resizer_ne = (widget) ->
+       return (dxy) ->
+               widget.resize w: widget.width + dxy.x, h: widget.height - dxy.y
+               widget.move x: widget.x, y: widget.y + dxy.y
+resizer_e = (widget) ->
+       return (dxy) ->
+               widget.resize w: widget.width + dxy.x, h: widget.height
+resizer_se = (widget) ->
+       return (dxy) ->
+               widget.resize w: widget.width + dxy.x, h: widget.height + dxy.y
+resizer_s = (widget) ->
+       return (dxy) ->
+               widget.resize w: widget.width, h: widget.height + dxy.y
+resizer_sw = (widget) ->
+       return (dxy) ->
+               widget.resize w: widget.width - dxy.x, h: widget.height + dxy.y
+               widget.move x: widget.x + dxy.x, y: widget.y
+resizer_w = (widget) ->
+       return (dxy) ->
+               widget.resize w: widget.width - dxy.x, h: widget.height
+               widget.move x: widget.x + dxy.x, y: widget.y
+resizers = [
+       resizer_nw
+       resizer_n
+       resizer_ne
+       resizer_e
+       resizer_se
+       resizer_s
+       resizer_sw
+       resizer_w
+]
+resizer_shapes = [
+       -> return "M#{@x - 5} #{@y - 5}h6l-2 2 4 4 2 -2v6h-6l2-2-4-4-2 2z"
+       -> 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"
+       -> return "M#{@x + 5} #{@y - 5}v6l-2-2-4 4 2 2h-6v-6l2 2 4-4-2-2z"
+       -> 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"
+]
+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"
+
 next_widget_id = 0
 # public vars: x, y, width, height, el
 class Visible
@@ -65,25 +105,89 @@ class Visible
                @y = args.y ? 1
                @width = args.width ? 50
                @height = args.height ? 34
-               @style = args.style ? STYLE_NORMAL
+               @state = args.state ? STATES.NORMAL
+               @hover = false
        destruct: ->
-       move: (args) ->
-               @x = args.x
-               @y = args.y
+       update_class: ->
+               css_class = "#{@css_class} #{@state.txt}"
+               if @hover
+                       css_class += " hover"
+               @el.setAttribute 'class', css_class
+       set_hover: (tf) ->
+               if tf != @hover
+                       @hover = tf
+                       @update_class()
+       move: (xy) -> # just move
+               @x = xy.x
+               @y = xy.y
+       drag: (dxy) -> # react to mouse drag (obey constraints, etc.)
+               @move x: @x + dxy.x, y: @y + dxy.y
        proximity: (xy) -> # return the square of the distance to your visible bits
-               return PROX_MAX + 1
-       set_style: (style) ->
-               @style = style
+               return PROX_TOO_FAR
+       set_state: (state) ->
+               @state = state
 
 class Control extends Visible
+       constructor: (args) ->
+               super args
+               @type = TYPE_CONTROL
+               @on_drag = args.drag
+               @on_destruct = args.done ? null
+       destruct: ->
+               super()
+               if @on_destruct?
+                       @on_destruct @
+       drag: (args) -> # call this when control point is being manipulated directly
+               @on_drag args
+       proximity: (xy) -> # return the square of the distance to your visible bits
+               dx = xy.x - @x
+               dy = xy.y - @y
+               return dx * dx + dy * dy
+class ControlPath extends Control
+       constructor: (args) ->
+               super args
+               @css_class = 'control_point'
+               @make_path = args.shape
+               @el = json_to_svg path:
+                       d: @make_path()
+                       class: 'control_point normal'
+               @svg.appendChild @el
+       destruct: ->
+               super()
+               if @el?
+                       @svg.removeChild @el
+       move: (args) ->
+               super args
+               @el.setAttribute 'd', @make_path()
 
 class Widget extends Visible
        #sub-classes are expected to implement all of these:
+       constructor: (args) ->
+               super args
+               @controls = []
+               @type = TYPE_WIDGET
+       destruct: ->
+               @kill_controls()
        clone: ->
                return new Widget @
-       controls: -> # create controls, return them
+       make_controls: -> # create controls, return them
                return []
-       hide_controls: ->
+       kill_controls: ->
+               for c in @controls
+                       c.destruct()
+               @controls = []
+               return
+       move: (xy) -> # just move
+               dx = xy.x - @x
+               dy = xy.y - @y
+               super xy
+               for c in @controls
+                       c.move x: c.x + dx, y: c.y + dy
+       set_state: (state) ->
+               return if @state is state
+               if @state is STATES.EDITING
+                       @kill_controls()
+               super state
 
 class RectWidget extends Widget
        constructor: (args) ->
@@ -102,17 +206,18 @@ class RectWidget extends Widget
                        @svg.removeChild @el
        clone: ->
                return new RectWidget @
-       set_style: (style) ->
-               super style
-               set_style_class el: @el, class: 'box', style: style
+       set_state: (state) ->
+               super state
+               @update_class()
        move: (args) ->
                super args
                @el.setAttribute 'x', @x + 1
                @el.setAttribute 'y', @y + 1
+               @reposition_controls()
        proximity: (xy) -> # return the square of the distance to your visible bits
                x = xy.x
                y = xy.y
-               prox = PROX_MAX + 1
+               prox = PROX_TOO_FAR
                in_x = false
                in_y = false
                if x > @x - CLICK_FUZ and x < @x + @width + CLICK_FUZ
@@ -133,12 +238,178 @@ class RectWidget extends Widget
                        new_prox *= new_prox
                        if new_prox < prox
                                prox = new_prox
-               if in_x and in_y and prox > PROX_MAX
-                       prox = PROX_MAX - 1
+               # "hit" anything inside
+               #if in_x and in_y and prox > PROX_MAX
+               #       prox = PROX_MAX - 1
                return prox
-       controls: -> # create controls, return them
-               return []
-       hide_controls: ->
+       resize: (wh) ->
+               dw = wh.w - @width
+               dh = wh.h - @height
+               @width = wh.w
+               @el.setAttribute 'width', @width - 2
+               @height = wh.h
+               @el.setAttribute 'height', @height - 2
+               @reposition_controls()
+       reposition_controls: ->
+               if @controls.length > 1
+                       positions = @control_positions()
+                       for i in [0...positions.length]
+                               @controls[i].move x: positions[i].x, y: positions[i].y
+       control_positions: ->
+               gap = 7
+               mgap = 9
+               w2p = Math.floor(@width / 2) + 0.5
+               h2p = Math.floor(@height / 2) + 0.5
+               return [
+                       { x: @x - gap, y: @y - gap }
+                       { x: @x + w2p, y: @y - mgap }
+                       { x: @x + @width + gap, y: @y - gap }
+                       { x: @x + @width + mgap, y: @y + h2p }
+                       { x: @x + @width + gap, y: @y + @height + gap }
+                       { x: @x + w2p, y: @y + @height + mgap }
+                       { x: @x - gap, y: @y + @height + gap }
+                       { x: @x - mgap, y: @y + h2p }
+               ]
+       make_controls: (args) -> # create controls, return them
+               if @controls.length > 0
+                       if console?.log?
+                               console.log "warning: re-adding controls"
+                       @kill_controls()
+               positions = @control_positions()
+               for i in [0...positions.length]
+                       @controls.push new ControlPath {
+                               svg: @svg
+                               x: positions[i].x
+                               y: positions[i].y
+                               done: args.done
+                               drag: resizers[i] @
+                               shape: resizer_shapes[i % resizer_shapes.length]
+                       }
+               return @controls
+
+class PolylineWidget extends Widget
+       constructor: (args) ->
+               super args
+               @css_class = 'polyline'
+               @nodes = []
+               for n in args.nodes
+                       @nodes.push x: n.x, y: n.y
+               @el = json_to_svg path:
+                       d: @my_path_d()
+                       class: 'polyline normal'
+               @svg.appendChild @el
+       destruct: ->
+               super()
+               if @el?
+                       @svg.removeChild @el
+       clone: ->
+               return new PolylineWidget @
+       my_path_d: ->
+               ret = ''
+               for n in @nodes
+                       if ret is ''
+                               ret += 'M'
+                       else
+                               ret += 'L'
+                       ret += n.x + @x
+                       ret += ' '
+                       ret += n.y + @y
+               return ret
+       set_state: (state) ->
+               super state
+               @update_class()
+       move: (args) ->
+               super args
+               @el.setAttribute 'd', @my_path_d()
+               @reposition_controls()
+       proximity: (xy) -> # return the square of the distance to your visible bits
+               prox = PROX_TOO_FAR
+               for n, i in @nodes
+                       dx = @x + n.x - xy.x
+                       dy = @y + n.y - xy.y
+                       p = dx * dx + dy * dy
+                       if p < prox
+                               prox = p
+                       if i > 0
+                               l1x = @x + @nodes[i-1].x
+                               l1y = @y + @nodes[i-1].y
+                               l2x = @x + @nodes[i].x
+                               l2y = @y + @nodes[i].y
+                               ldx = l2x - l1x
+                               ldy = l2y - l1y
+                               if ldx is 0 # vertical line
+                                       if (xy.y < l1y) is (xy.y < l2y)
+                                               continue
+                                       dx = l1x - xy.x
+                                       p = dx * dx
+                               else if ldy is 0 # horizontal line
+                                       if (xy.x < l1x) is (xy.x < l2x)
+                                               continue
+                                       dy = l1y - xy.y
+                                       p = dy * dy
+                               else # slanty line
+                                       # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
+                                       a = ldy
+                                       b = -1 * ldx
+                                       c = l2x * l1y - l2y * l1x
+                                       y_on_line = (a * (a * xy.y - b * xy.x) - b * c) / (a * a + b * b)
+                                       if (y_on_line < l1y) is (y_on_line < l2y)
+                                               continue
+                                       p = (a * xy.x + b * xy.y + c) / Math.sqrt(a * a + b * b)
+                                       p *= p
+
+                               if p < prox
+                                       prox = p
+               return prox
+       resize: (wh) ->
+               # FIXME (apply to more than just 2nd node)
+               @nodes[1].x = wh.w
+               @nodes[1].y = wh.h
+               @width = wh.w
+               @height = wh.h
+               @el.setAttribute 'd', @my_path_d()
+               @reposition_controls()
+               return
+       node_dragger: (i) ->
+               if i is 0
+                       return (dxy) =>
+                               for i in [1...@nodes.length]
+                                       @nodes[i].x -= dxy.x
+                                       @nodes[i].y -= dxy.y
+                               @move x: @x + dxy.x, y: @y + dxy.y
+               return (dxy) =>
+                       @nodes[i].x += dxy.x
+                       @nodes[i].y += dxy.y
+                       @el.setAttribute 'd', @my_path_d()
+                       @reposition_controls()
+       reposition_controls: ->
+               if @controls.length > 1
+                       positions = @control_positions()
+                       for i in [0...positions.length]
+                               @controls[i].move x: positions[i].x, y: positions[i].y
+               return
+       control_positions: ->
+               ret = []
+               for n in @nodes
+                       ret.push x: @x + n.x, y: @y + n.y
+               return ret
+       make_controls: (args) -> # create controls, return them
+               console.log 'make line controls'
+               if @controls.length > 0
+                       if console?.log?
+                               console.log "warning: re-adding controls"
+                       @kill_controls()
+               positions = @control_positions()
+               for i in [0...positions.length]
+                       @controls.push new ControlPath {
+                               svg: @svg
+                               x: positions[i].x
+                               y: positions[i].y
+                               done: args.done
+                               drag: @node_dragger i
+                               shape: shape_node_move
+                       }
+               return @controls
 
 # called automatically on domcontentloaded
 init = ->
@@ -156,7 +427,7 @@ init = ->
                ]
        svg.appendChild json_to_svg style:
                type: 'text/css'
-               contents: '.box.normal,.box.hover,.box.selected{filter: url(#crayon)}'
+               contents: '.box.normal,.polyline.normal{filter: url(#crayon)}'
 
        # create canvas border
        svg.appendChild json_to_svg rect:
@@ -167,44 +438,73 @@ init = ->
                class: 'canvas_border'
 
        supply = {}
-       for args, i in [
-               { width: 40, height: 40 }
-               { width: 12, height: 50 }
-               { width: 70, height: 12 }
-       ]
-               widget = new RectWidget {
-                       width: args.width
-                       height: args.height
-                       x: 30 + i * 90 + (70 - args.width) / 2
-                       y: (supply_height - args.height) / 2
-                       svg: svg
-               }
-               supply[widget.id] = widget
+       supply_count = 0
+       supply_add = (type, args) ->
+               args.x ?= 0
+               args.y ?= 0
+               args.x += 30 + supply_count * 90
+               args.y += (supply_height - 50) / 2
+               args.svg = svg
+               w = new type args
+               supply[w.id] = w
+               supply_count += 1
+       supply_add RectWidget, width: 50, height: 50
+       supply_add PolylineWidget, y: 25, nodes: [{x: 0, y: 0}, {x: 50, y: 0}]
+       supply_add PolylineWidget, x: 25, nodes: [{x: 0, y: 0}, {x: 0, y: 50}]
+       supply_add PolylineWidget, x: 10, nodes: [{x: 0, y: 0}, {x: 15, y: 50}, {x: 30, y: 0}]
+       supply_add PolylineWidget, nodes: [{x: 0, y: 50}, {x: 17, y: 0}, {x: 33, y: 50}, {x: 50, y: 0}]
 
        # editor state
-       on_canvas = {}
-       selected = {}
-       editing = {} # has controls
-       dragging = false
+       controls_layer = { all: {}, selected: {} }
+       widget_layer = { all: {}, selected: {}, editing: null }
+       layers = [controls_layer, widget_layer]
+       hovered = null # can be in any layer
+       dragging = false # mouse state
+       drag_layer = null
        drag_from = x: 0, y: 0 # mouse was here at last frame of drag
        shift_key_down = false
 
-       deselect_all = (args) ->
-               except = args?.except ? null
-               for id, w of selected
-                       w.set_style STYLE_NORMAL
-                       delete selected[id]
-       closest_widget = (widgets, xy) ->
-               prox = PROX_MAX + 1
+       stop_editing = ->
+               if widget_layer.editing
+                       widget_layer.editing.kill_controls()
+                       widget_layer.editing = null
+       deselect = (layer, s) ->
+               return unless layer.selected[s.id]?
+               s.set_state STATES.NORMAL
+               delete layer.selected[s.id]
+               if widget_layer.editing is s
+                       widget_layer.editing = null
+               return
+       deselect_all = (layer, except = null) ->
+               for id, s of layer.selected
+                       deselect layer, s
+               return
+       _select = (layer, s) -> # don't call this directly, use select_only() or select_also()
+               s.set_state STATES.SELECTED
+               layer.selected[s.id] = s
+               return
+       select_only = (layer, s) ->
+               deselect_all layer, s
+               return if layer.selected[s.id]?
+               _select layer, s
+               return
+       select_also = (layer, s) ->
+               return if layer.selected[s.id]?
+               if layer is widget_layer
+                       stop_editing()
+               _select layer, s
+               return
+       find_closest = (widgets, xy) ->
+               prox = PROX_TOO_FAR
                closest = null
                for id, w of widgets
                        new_prox = w.proximity xy
                        if new_prox < prox
                                prox = new_prox
                                closest = w
-               if prox < PROX_MAX
-                       return closest
-               return null
+               if prox > PROX_MAX
+                       return null
+               return closest
        svg_event_to_xy = (e) ->
                unless svg_offset?
                        svg_offset = $svg.offset()
@@ -212,40 +512,73 @@ init = ->
                        x: Math.round(e.pageX - svg_offset.left)
                        y: Math.round(e.pageY - svg_offset.top)
                }
+       closest_in_layers = (xy) ->
+               for layer in layers
+                       s = find_closest layer.selected, xy
+                       return layer: layer, s: s if s?
+                       s = find_closest layer.all, xy
+                       return layer: layer, s: s if s?
+               return null
        mousedown = (e) ->
+               hit = null
+               closest = null
+               layer = null
                mousemove e
                if dragging # two mousedowns in a row?! it happens
                        return mouseup e
                xy = svg_event_to_xy e
                if xy.y < supply_height
-                       closest = closest_widget supply, xy
-                       if closest?
-                               closest = closest.clone()
-                               on_canvas[closest.id] = closest
+                       s = find_closest supply, xy
+                       if s?
+                               hit = {
+                                       s: s.clone()
+                                       layer: widget_layer
+                               }
+                               widget_layer.all[hit.s.id] = hit.s
                else
-                       closest = closest_widget on_canvas, xy
-               if closest?
-                       unless (shift_key_down or selected[closest.id]?)
-                               deselect_all except: closest
-                       selected[closest.id] = closest
-                       closest.set_style STYLE_DRAGGING
+                       hit = closest_in_layers xy
+               if hit?
+                       if hit.layer.selected[hit.s.id]
+                               # already selected
+                               # TODO start detection of a click that doesn't drag (to shrink selection)
+                       else if xy.y < supply_height
+                               # dragging a new thing in
+                               select_only hit.layer, hit.s
+                       else if shift_key_down
+                               select_also hit.layer, hit.s
+                       else
+                               select_only hit.layer, hit.s
+                       for id, s of hit.layer.selected
+                               s.set_state STATES.DRAGGING
                        dragging = true
+                       drag_layer = hit.layer
                        drag_from = xy
                else
-                       deselect_all()
-               return false
+                       deselect_all widget_layer
+               return
        mouseup = (e) ->
                mousemove e
                if dragging
-                       for id, w of selected
-                               if w.y < supply_height
-                                       w.destruct()
-                                       delete selected[id]
+                       selected_count = 0
+                       for id, s of drag_layer.selected
+                               if s.y < supply_height and drag_layer is widget_layer
+                                       deselect drag_layer, s
+                                       s.destruct()
+                                       delete drag_layer.all[id]
                                else
-                                       w.set_style STYLE_SELECTED
+                                       selected_count += 1
+                                       s.set_state STATES.SELECTED
+                       if drag_layer is widget_layer and selected_count is 1
+                               for id, s of drag_layer.selected
+                                       s.set_state STATES.EDITING
+                                       cs = s.make_controls done: (c) ->
+                                               deselect controls_layer, c
+                                               delete controls_layer.all[c.id]
+                                       for c in cs
+                                               controls_layer.all[c.id] = c
+                                       widget_layer.editing = s
                dragging = false
-               return false
-       prev_hover = null
+               return
        mousemove = (e) ->
                xy = svg_event_to_xy e
                if dragging
@@ -253,26 +586,25 @@ init = ->
                        rel_x = xy.x - drag_from.x
                        rel_y = xy.y - drag_from.y
                        drag_from = xy
-                       for id, w of selected
-                               w.move x: w.x + rel_x, y: w.y + rel_y
+                       for id, w of drag_layer.selected
+                               w.drag x: rel_x, y: rel_y
                else
-                       hover = closest_widget on_canvas, xy
-                       unless hover?
-                               hover = closest_widget supply, xy
-                       if hover != prev_hover
-                               if prev_hover?
-                                       # FIXME
-                                       if selected[prev_hover.id]?
-                                               prev_hover.set_style STYLE_SELECTED
-                                       else
-                                               prev_hover.set_style STYLE_NORMAL
-                               if hover?
-                                       hover.set_style STYLE_HOVER
-                               prev_hover = hover
+                       hit = closest_in_layers xy
+                       if hovered and hovered isnt hit?.s
+                               hovered.set_hover false
+                       return unless hit?
+                       hovered = hit.s
+                       hovered.set_hover true
+               return
+       $svg.mousedown (e) ->
+               mousedown e
+               return false
+       $svg.mouseup (e) ->
+               mouseup e
+               return false
+       $svg.mousemove (e) ->
+               mousemove e
                return false
-       $svg.mousedown mousedown
-       $svg.mouseup mouseup
-       $svg.mousemove mousemove
        $(document).on 'keyup keydown', (e) ->
                shift_key_down = e.shiftKey
                return true