# Copyright 2015 Jason Woofenden # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more # details. # # You should have received a copy of the GNU General Public License along with # this program. If not, see . # settings width = 800 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 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) -> for tag, attrs of json el = document.createElementNS 'http://www.w3.org/2000/svg', tag for k, v of attrs if k is 'children' for child in v el.appendChild json_to_svg child else if k is 'contents' el.appendChild document.createTextNode v else 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 # required args: svg constructor: (args) -> @id = next_widget_id next_widget_id += 1 @svg = args.svg @x = args.x ? 1 @y = args.y ? 1 @width = args.width ? 50 @height = args.height ? 34 @state = args.state ? STATES.NORMAL @hover = false destruct: -> 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_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 @ make_controls: -> # create controls, return them return [] 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) -> super args @css_class = 'box' @el = json_to_svg rect: x: @x + 1 y: @y + 1 width: @width - 2 height: @height - 2 class: 'box normal' @svg.appendChild @el destruct: -> super() if @el? @svg.removeChild @el clone: -> return new RectWidget @ 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_TOO_FAR in_x = false in_y = false if x > @x - CLICK_FUZ and x < @x + @width + CLICK_FUZ in_x = true if y < @y + @height / 2 new_prox = @y - y else new_prox = @y + @height - y new_prox *= new_prox if new_prox < prox prox = new_prox if y > @y - CLICK_FUZ and y < @y + @height + CLICK_FUZ in_y = true if x < @x + @width / 2 new_prox = @x - x else new_prox = @x + @width - x 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 return prox 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 = -> svg_offset = null $container = $ '.crayon_mockup' svg = json_to_svg svg: width: width, height: height, viewBox: "0 0 #{width} #{height}" $svg = $ svg $container.append $svg svg.appendChild json_to_svg filter: id: 'crayon', filterUnits: 'userSpaceOnUse' x: '-5%', y: '-5%', height: '110%', width: '110%' children: [ { feTurbulence: baseFrequency: '.3', numOctaves: '2', type: 'fractalNoise' } { feDisplacementMap: scale: '6', xChannelSelector: 'R', in: 'SourceGraphic' } ] svg.appendChild json_to_svg style: type: 'text/css' contents: '.box.normal,.polyline.normal{filter: url(#crayon)}' # create canvas border svg.appendChild json_to_svg rect: x: 1 y: supply_height + 1 width: width - 2 height: height - 2 - supply_height class: 'canvas_border' supply = {} 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, x: 0, nodes: [{x: 0, y: 50}, {x: 17, y: 0}, {x: 33, y: 50}, {x: 50, y: 0}] # editor state 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 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 null return closest svg_event_to_xy = (e) -> unless svg_offset? svg_offset = $svg.offset() return { 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 s = find_closest supply, xy if s? hit = { s: s.clone() layer: widget_layer } widget_layer.all[hit.s.id] = hit.s else 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 widget_layer return mouseup = (e) -> mousemove e if dragging 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 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 mousemove = (e) -> xy = svg_event_to_xy e if dragging return if drag_from.x is xy.x and drag_from.y is xy.y rel_x = xy.x - drag_from.x rel_y = xy.y - drag_from.y drag_from = xy for id, w of drag_layer.selected w.drag x: rel_x, y: rel_y else 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 $(document).on 'keyup keydown', (e) -> shift_key_down = e.shiftKey return true #($ document).keydown (e) -> $ init