# 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_UNKNOWN = 0 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) -> @type = TYPE_UNKNOWN @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 @shown = false @el = null return destruct: -> @hide() return update_class: -> css_class = "#{@css_class} #{@state.txt}" if @hover css_class += " hover" @el.setAttribute 'class', css_class return set_hover: (bool) -> if bool != @hover @hover = bool @update_class() return hide: -> if @el and @shown @svg.removeChild @el @shown = false return show: -> if @el? and not @shown @svg.appendChild @el @shown = true return move: (xy) -> # just move @x = xy.x @y = xy.y return drag: (dxy) -> # react to mouse drag (obey constraints, etc.) @move x: @x + dxy.x, y: @y + dxy.y return proximity: (xy) -> # return the square of the distance to your visible bits return PROX_TOO_FAR set_state: (state) -> @state = state @update_class() return class Control extends Visible constructor: (args) -> super args @type = TYPE_CONTROL @on_drag = args.drag return drag: (args) -> # call this when control point is being manipulated directly @on_drag args return 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' return move: (args) -> super args @el.setAttribute 'd', @make_path() return # A widget is a visable thing that can be edited via controls class Widget extends Visible constructor: (args) -> super args @type = TYPE_WIDGET @controls = null @controls_shown = false destruct: -> super() @hide_controls() return clone: -> return new Widget @ make_controls: -> @controls = [] return hide_controls: -> return unless @controls? and @controls_shown for c in @controls c.hide() @controls_shown = false return show_controls: -> return if @controls? and @controls_shown unless @controls? @make_controls() return unless @controls? for c in @controls c.show() @controls_shown = true return move: (xy) -> dx = xy.x - @x dy = xy.y - @y super xy if @controls? for c in @controls c.move x: c.x + dx, y: c.y + dy return set_state: (state) -> return if @state is state if @state is STATES.EDITING @hide_controls() super state return 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' @show() return clone: -> return new RectWidget @ as_array: -> return [@x, @y, @width, @height] from_array: (args, a) -> args.x = a[0] args.y = a[1] args.width = a[2] args.height = a[3] return new RectWidget args move: (args) -> super args @el.setAttribute 'x', @x + 1 @el.setAttribute 'y', @y + 1 @reposition_controls() return 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 # "hit" anything inside #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() return reposition_controls: -> if @controls? and @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: -> 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: -> @controls = [] positions = @control_positions() for i in [0...positions.length] @controls.push new ControlPath { svg: @svg x: positions[i].x y: positions[i].y drag: resizers[i] @ shape: resizer_shapes[i % resizer_shapes.length] } return 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' @show() as_array: -> ret = [] for n in @nodes ret.push @x + n.x ret.push @y + n.y return ret from_array: (args, a) -> # the first node is also used as the @x,@y args.x = a[0] args.y = a[1] args.nodes = [] while a.length xy = {} xy.x = a.shift() - args.x xy.y = a.shift() - args.y args.nodes.push xy return new PolylineWidget args 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 move: (args) -> super args @el.setAttribute 'd', @my_path_d() @reposition_controls() return 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) -> # TODO implement #@el.setAttribute 'd', @my_path_d() #@reposition_controls() return node_dragger: (i) -> if i is 0 return (dxy) => for j in [1...@nodes.length] @nodes[j].x -= dxy.x @nodes[j].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? and @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: -> @controls = [] positions = @control_positions() for i in [0...positions.length] @controls.push new ControlPath { svg: @svg x: positions[i].x y: positions[i].y drag: @node_dragger i shape: shape_node_move } return CSS_CLASS_TO_PICKLE_TYPE = { box: '0' polyline: '1' } PICKLE_TYPE_TO_WIDGET_CLASS = [ RectWidget PolylineWidget ] # 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, y: 50, nodes: [{x: 0, y: 0}, {x: 17, y: -50}, {x: 33, y: 0}, {x: 50, y: -50}] # editor state widgets = {} selected = {} selected_count = 0 hovered = null drag_targets = null dragging = false # mouse state drag_from = x: 0, y: 0 # mouse was here at last frame of drag shift_key_down = false lc = "abcdefghijklmnopqrstuvwxyz" uc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" cc_lca = lc.charCodeAt 0 cc_lcz = lc.charCodeAt 25 cc_uca = uc.charCodeAt 0 cc_ucz = uc.charCodeAt 25 cc_0 = '0'.charCodeAt 0 cc_9 = '9'.charCodeAt 0 # takes an array of positive integers, and encodes it as a string pickle = (a) -> ret = '' for i in a cs = lc r = '' while i > 0 or r is '' digit = i % cs.length i -= digit i /= cs.length r = cs.charAt(digit) + r cs = uc ret += r return ret pickle_widgets = -> ret = '0' # version of this encoding scheme for id, w of widgets if CSS_CLASS_TO_PICKLE_TYPE[w.css_class]? ret += CSS_CLASS_TO_PICKLE_TYPE[w.css_class] ret += pickle w.as_array() return ret save = -> window.location.hash = pickle_widgets() load = (str) -> return if str.charAt(1) isnt '0' wtype = null args = [] ii = 0 load_1 = (next_type) -> unless wtype? if next_type? wtype = next_type return w = PICKLE_TYPE_TO_WIDGET_CLASS[wtype]::from_array svg: svg, args widgets[w.id] = w wtype = next_type args = [] ii = 0 for i in [2...str.length] c = str.charCodeAt(i) if cc_0 <= c <= cc_9 load_1 c - cc_0 else if cc_lca <= c <= cc_lcz ii *= lc.length ii += c - cc_lca args.push ii ii = 0 else if cc_uca <= c <= cc_ucz ii *= lc.length ii += c - cc_uca load_1 null return stop_editing = -> for id, w of widgets w.hide_controls() return deselect = (w) -> return unless selected[w.id]? w.set_state STATES.NORMAL delete selected[w.id] selected_count -= 1 w.hide_controls() return deselect_all = (except = null) -> for id, w of selected if w isnt except deselect w return _select = (w) -> # don't call this directly, use select_only() or select_also() w.set_state STATES.SELECTED selected[w.id] = w return select_only = (w) -> deselect_all w return if selected[w.id]? _select w return select_also = (w) -> return if selected[w.id]? stop_editing() _select w 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 find_closest_control = (xy) -> prox = PROX_TOO_FAR closest = null for id, s of selected if s.controls_shown for c in s.controls if c.shown new_prox = c.proximity xy if new_prox < prox prox = new_prox closest = c if prox > PROX_MAX return null return closest find_closest_thing = (xy) -> if xy.y < supply_height return find_closest supply, xy else hit = find_closest_control xy hit ?= find_closest widgets, xy return hit 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) } mousedown = (e) -> hit = null closest = 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 hit = find_closest supply, xy if hit? hit = hit.clone() widgets[hit.id] = hit else hit = find_closest_control xy unless hit? hit = find_closest widgets, xy if hit? if hit.type is TYPE_WIDGET if selected[hit.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 else if shift_key_down select_also hit else select_only hit for id, w of selected w.set_state STATES.DRAGGING drag_targets = selected else drag_targets = {} drag_targets[hit.id] = hit dragging = true drag_from = xy else deselect_all() return mouseup = (e) -> save() mousemove e if dragging widgets_remaining = 0 for id, s of drag_targets if s.type is TYPE_WIDGET if s.y < supply_height deselect s s.destruct() delete widgets[id] else widgets_remaining += 1 s.set_state STATES.SELECTED if widgets_remaining is 1 for id, s of drag_targets if s.type is TYPE_WIDGET s.set_state STATES.EDITING s.show_controls() 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_targets w.drag x: rel_x, y: rel_y else hit = find_closest_thing xy return if hit is hovered # both null, or both the same return if hit?.type is TYPE_CONTROL # hovering a control doesn't change display if hovered? hovered.set_hover false if hit? hovered = hit hit.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) -> if window.location.hash load window.location.hash $ init