diff --git a/src/coffee/constants.coffee b/src/coffee/constants.coffee index dd1563716..6fde06dd6 100644 --- a/src/coffee/constants.coffee +++ b/src/coffee/constants.coffee @@ -36,6 +36,7 @@ Duration.slow = 1200 exports.Event = Event = {} Event.add = 'add' # collection, item... Event.change = 'change' # model +Event.click = 'click' # event Event.load = {} Event.load.started = 'load:started' # controller, url Event.load.succeeded = 'load:succeeded' # controller, book @@ -60,8 +61,12 @@ Event.transitionEnd = (-> return eventName if el.style[styleName]? )() -exports.Key = Key = {} -Key.Return = 13 +exports.Key = Key = {} +Key.Enter = 13 +Key.Return = 13 +Key.Escape = 27 +Key.UpArrow = 38 +Key.DownArrow = 40 exports.Login = Login = {} Login.authorizeUrl = _.template "https://github.com/login/oauth/authorize" + diff --git a/src/coffee/controllers/base_controller.coffee b/src/coffee/controllers/base_controller.coffee index 8df78d01c..2aa43fbec 100644 --- a/src/coffee/controllers/base_controller.coffee +++ b/src/coffee/controllers/base_controller.coffee @@ -13,12 +13,14 @@ views = require '../views' module.exports = class BaseController extends Backbone.View constructor: (options={})-> + options.useAnimations ?= true @tryRefresh = _.debounce @_tryRefresh, 100 - @_model = null - @_rendered = false - @_parent = options.parent - @_children = [] + @_model = null + @_rendered = false + @_parent = options.parent + @_children = [] + @_useAnimations = options.useAnimations @_loadTemplate options.templateName super options @@ -55,8 +57,12 @@ module.exports = class BaseController extends Backbone.View logger.verbose => "#{this} refreshing" remove: -> - logger.verbose => "#{this} is removing its element from the DOM" - @hide => @$el.remove() + if @_useAnimations + @hide => + logger.verbose => "#{this} is removing its element from the DOM" + @$el.remove() + else + @$el.remove() routeLinkClick: (event)-> event.preventDefault() @@ -158,14 +164,14 @@ module.exports = class BaseController extends Backbone.View $renderedEl = $($(@_template(data))[0]) @onWillRender() - @hide() + @hide() if @_useAnimations @unrender() @$el.append $renderedEl.children() @$el.addClass $renderedEl.attr 'class' @$el.data $renderedEl.data() @delegateEvents() - @show() if options.show + @show() if options.show and @_useAnimations @_rendered = true @onDidRender() diff --git a/src/coffee/controllers/header_controller.coffee b/src/coffee/controllers/header_controller.coffee index 5d8db72f2..4eaf010e8 100644 --- a/src/coffee/controllers/header_controller.coffee +++ b/src/coffee/controllers/header_controller.coffee @@ -5,11 +5,12 @@ Copyright (c) 2015 by Redwood Labs All rights reserved. ### -BaseController = require './base_controller' -{Duration} = require '../constants' -{Event} = require '../constants' -{ProductionEnvs} = require '../constants' -{Url} = require '../constants' +BaseController = require './base_controller' +ItemSelectorController = require './item_selector_controller' +{Duration} = require '../constants' +{Event} = require '../constants' +{ProductionEnvs} = require '../constants' +{Url} = require '../constants' ######################################################################################################################## @@ -17,10 +18,12 @@ module.exports = class HeaderController extends BaseController constructor: (options={})-> if not options.client? then throw new Error 'options.client is required' + if not options.modPack? then throw new Error 'options.modPack is required' super options - @client = options.client - @_user = options.user + @client = options.client + @modPack = options.modPack + @_user = options.user # Event Methods ################################################################################ @@ -78,6 +81,10 @@ module.exports = class HeaderController extends BaseController addThisUrl = '//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-54f3c9717b2e530d' $('body').append "" + @_itemSelector = @addChild ItemSelectorController, '.view__item_selector', + modPack: @modPack + onChoseItem: (itemSlug)=> @_goToItem(itemSlug) + refresh: -> if @user? @$loginName.html "Hello #{router.user.login}!" @@ -105,3 +112,8 @@ module.exports = class HeaderController extends BaseController 'click a.logo': 'onLogoClicked' 'click .navBar a': 'onNavItemClicked' 'click .login a': 'onLoginLinkClicked' + + # Private Methods ############################################################################## + + _goToItem: (itemSlug)-> + router.navigate Url.item(itemSlug:itemSlug.item, modSlug:itemSlug.mod), trigger:true diff --git a/src/coffee/controllers/item_selector_controller.coffee b/src/coffee/controllers/item_selector_controller.coffee new file mode 100644 index 000000000..adecafce9 --- /dev/null +++ b/src/coffee/controllers/item_selector_controller.coffee @@ -0,0 +1,185 @@ +### +Crafting Guide - item_selector_controller.coffee + +Copyright (c) 2015 by Redwood Labs +All rights reserved. +### + +BaseController = require './base_controller' +ItemSelector = require '../models/item_selector' +ItemSelectorElementController = require './item_selector_element_controller' +{Duration} = require '../constants' +{Event} = require '../constants' +{Key} = require '../constants' + +######################################################################################################################## + +module.exports = class ItemSelectorController extends BaseController + + constructor: (options)-> + if not options.modPack? then throw new Error 'options.modPack is required' + options.model ?= new ItemSelector {}, modPack:options.modPack + options.onChoseItem ?= (item)-> # do nothing + options.templateName = 'item_selector' + super options + + @modPack = options.modPack + @onChoseItem = options.onChoseItem + + @_elementControllers = [] + + # Event Methods ################################################################################ + + onHintKeyPress: (event)-> + switch event.which + when Key.Escape + @_close() + return false + when Key.Enter + @_chooseSelected() + return false + when Key.DownArrow + @_selectNext() + return false + when Key.UpArrow + @_selectPrevious() + return false + + onHintChanged: (event)-> + @model.hint = @$hintField.val() + @tryRefresh() + @onResultSelected @_elementControllers[0] + + onLaunchButtonClicked: (event)-> + event.preventDefault() + @model.hint = '' + @refresh() + + $('body').addClass 'stop-scrolling' + + @$screen.append @$popup + @$screen.css 'display', 'block' + + @$popup.offset @$el.offset() + @$popup.css 'width', @$el.css 'width' + @$popup.off Event.click + @$popup.on Event.click, (event)=> @onPopupClicked(event) + @$popup.removeClass 'hiding' + + @$hintField.on 'keyup', (event)=> @onHintKeyPress(event) + @$hintField.on 'input', (event)=> @onHintChanged(event) + + @$popup.off Event.transitionEnd + @$popup.one Event.transitionEnd, => @onPopupDisplayed() + @$screen.one Event.click, (event)=> @onScreenClicked(event) + + onPopupClicked: (event)-> + return false + + onPopupDisplayed: -> + @$hintField.focus() + + onResultClicked: (controller)-> + @_close() + @onChoseItem controller.model.slug + + onResultSelected: (controller)-> + for c in @_elementControllers + c.selected = false + + if controller? + controller.selected = true + + onScreenClicked: (event)-> + @_close() + return false + + # BaseController Overrides ##################################################################### + + onDidRender: -> + @$screen = $('.view__screen') # find the shared, global screen + @$popup = @$('.view__item_selector_popup') + @$hintField = @$('.view__item_selector_popup input') + @$searchInput = @$('.search input') + @$resultsContainer = @$('.results') + + @$popup.detach() + super + + refresh: -> + @$hintField.val @model.hint + @_refreshResults() + super + + # Backbone.View Overrides ###################################################################### + + events: -> + return _.extend super, + 'click': 'onLaunchButtonClicked' + + # Private Methods ############################################################################## + + _chooseSelected: -> + for controller in @_elementControllers + if controller.selected + @onResultClicked controller + return + + _close: -> + $('body').removeClass 'stop-scrolling' + + @$popup.addClass 'hiding' + + @$popup.off Event.transitionEnd + @$popup.one Event.transitionEnd, => + @$screen.css 'display', 'none' + @$popup.detach() + + _refreshResults: -> + index = 0 + + for itemSlug in @model.results + displayModel = @modPack.findItemDisplay itemSlug + controller = @_elementControllers[index] + if not controller? + controller = new ItemSelectorElementController + model: displayModel + onClicked: (c)=> @onResultClicked(c) + onSelected: (c)=> @onResultSelected(c) + controller.render show:false + @_elementControllers[index] = controller + @$resultsContainer.append controller.$el + else + controller.model = displayModel + + controller.show() + index += 1 + + while @_elementControllers.length > index + @_elementControllers.pop().remove() + + _selectNext: -> + for i in [0...(@_elementControllers.length - 1)] by 1 + controller = @_elementControllers[i] + nextController = @_elementControllers[i + 1] + + if controller.selected + controller.selected = false + nextController.selected = true + @_showElement nextController.$el + return + + _selectPrevious: -> + for i in [1...@_elementControllers.length] by 1 + previousController = @_elementControllers[i - 1] + controller = @_elementControllers[i] + + if controller.selected + previousController.selected = true + controller.selected = false + @_showElement previousController.$el + return + + _showElement: ($el)-> + top = $el.position().top + @$resultsContainer.scrollTop() + @$resultsContainer.animate {scrollTop:top}, duration:Duration.snap diff --git a/src/coffee/controllers/item_selector_element_controller.coffee b/src/coffee/controllers/item_selector_element_controller.coffee new file mode 100644 index 000000000..5aefde168 --- /dev/null +++ b/src/coffee/controllers/item_selector_element_controller.coffee @@ -0,0 +1,79 @@ +### +Crafting Guide - item_selector_element_controller.coffee + +Copyright (c) 2015 by Redwood Labs +All rights reserved. +### + +BaseController = require './base_controller' +{Event} = require '../constants' + +######################################################################################################################## + +module.exports = class ItemSelectorElementController extends BaseController + + constructor: (options={})-> + if not options.model? then throw new Error 'options.model is required' + options.onClicked ?= (controller)-> # do nothing + options.onSelected ?= (controller)-> # do nothing + options.templateName = 'item_selector_element' + options.useAnimations = false + super options + + @onClicked = options.onClicked + @onSelected = options.onSelected + + # Property Methods ############################################################################# + + isSelected: -> + return @_selected + + setSelected: (newSelected)-> + oldSelected = @_selected + return if newSelected is oldSelected + + @_selected = newSelected + @tryRefresh() + + @trigger Event.change + ':selected', this, oldSelected, newSelected + @trigger Event.change, this + + Object.defineProperties @prototype, + selected: {get:@prototype.isSelected, set:@prototype.setSelected} + + # BaseController Overrides ##################################################################### + + onDidRender: -> + @$icon = @$('img') + @$name = @$('.name p') + @$modName = @$('.modName p') + super + + refresh: -> + @$icon.attr 'src', @model.iconUrl + @$name.html @model.itemName + @$modName.html @model.modName + + if @_selected + @$el.addClass 'selected' + else + @$el.removeClass 'selected' + + super + + # Backbone.View Overrides ###################################################################### + + events: -> + return _.extend super, + 'click': '_onClick' + 'mouseenter': '_onMouseEnter' + + # Private Methods ############################################################################## + + _onClick: (event)-> + event.preventDefault() + @onClicked this + + _onMouseEnter: (event)-> + event.preventDefault() + @onSelected this \ No newline at end of file diff --git a/src/coffee/models/item_selector.coffee b/src/coffee/models/item_selector.coffee new file mode 100644 index 000000000..f5431b028 --- /dev/null +++ b/src/coffee/models/item_selector.coffee @@ -0,0 +1,84 @@ +### +Crafting Guide - item_selector.coffee + +Copyright (c) 2015 by Redwood Labs +All rights reserved. +### + +BaseModel = require './base_model' +ItemSlug = require './item_slug' + +######################################################################################################################## + +module.exports = class ItemSelector extends BaseModel + + constructor: (attributes={}, options={})-> + if not options.modPack? then throw new Error 'options.modPack is required' + + super attributes, options + + @_maxResults = 100 + @_minHintLength = 3 + @_modPack = options.modPack + @_results = [] + + # Property Methods ############################################################################# + + getHint: -> + return @_hint + + setHint: (newHint)-> + oldHint = @_hint + return if newHint is oldHint + + @_hint = newHint.toLowerCase() + + @trigger Event.change + ':hint', this, oldHint, newHint + @_refreshResults() + @trigger Event.change, this + + getResults: -> + return @_results + + Object.defineProperties @prototype, + hint: {get:@prototype.getHint, set:@prototype.setHint} + results: {get:@prototype.getResults} + + # Private Methods ############################################################################## + + _isMatch: (name)-> + hintIndex = 0 + hintLetter = @_hint[hintIndex] + name = name.toLowerCase() + + for nameIndex in [0...name.length] by 1 + if name.charAt(nameIndex) is hintLetter + hintIndex += 1 + return true if hintIndex is @_hint.length + hintLetter = @_hint[hintIndex] + + return false + + _refreshResults: -> + oldResults = @_results + newResults = {} + count = 0 + + logger.verbose => "Looking for items which match: #{@_hint}" + if @_hint.length >= @_minHintLength + @_modPack.eachMod (mod)=> + return if count >= @_maxResults + + mod.eachName (name, itemSlug)=> + return if count >= @_maxResults + + if @_isMatch name + if itemSlug.isQualified + newResults[itemSlug] = itemSlug + count += 1 + + newResults = _.values(newResults) + newResults.sort ItemSlug.compare + + @_results = newResults + @trigger Event.change + ':results', this, oldResults, newResults diff --git a/src/coffee/models/item_slug.coffee b/src/coffee/models/item_slug.coffee index 6e5ee66fc..206ad2079 100644 --- a/src/coffee/models/item_slug.coffee +++ b/src/coffee/models/item_slug.coffee @@ -25,20 +25,10 @@ module.exports = class ItemSlug # Class Methods ################################################################################ @compare: (a, b)-> - aIsRequired = a.mod in RequiredMods - bIsRequired = b.mod in RequiredMods - - if aIsRequired isnt bIsRequired - return -1 if aIsRequired - return +1 if bIsRequired - else if a.isQualified isnt b.isQualified - return -1 if a.isQualified - return +1 if b.isQualified - else if a.mod isnt b.mod - return if a.mod < b.mod then -1 else +1 - else if a.item isnt b.item + if a.item isnt b.item return if a.item < b.item then -1 else +1 - + if a.mod isnt b.mod + return if a.mod < b.mod then -1 else +1 return 0 @equal: (a, b)-> diff --git a/src/coffee/models/mod_pack.coffee b/src/coffee/models/mod_pack.coffee index 4c0d2120f..27ec4ab99 100644 --- a/src/coffee/models/mod_pack.coffee +++ b/src/coffee/models/mod_pack.coffee @@ -53,22 +53,23 @@ module.exports = class ModPack extends BaseModel findItemDisplay: (itemSlug)-> if not itemSlug? then throw new Error 'itemSlug is required' - result = {} + result = {slug:itemSlug} item = @findItem itemSlug, includeDisabled:true if item? + result.itemName = item.name + result.itemSlug = item.slug.item result.modSlug = item.slug.mod result.modVersion = item.modVersion.version - result.itemSlug = item.slug.item - result.itemName = item.name else + result.itemName = @findName itemSlug, includeDisabled:true + result.itemSlug = itemSlug.item result.modSlug = @_mods[0].slug result.modVersion = @_mods[0].activeVersion - result.itemSlug = itemSlug.item - result.itemName = @findName itemSlug, includeDisabled:true result.craftingUrl = Url.crafting inventoryText:itemSlug.item result.iconUrl = Url.itemIcon result result.itemUrl = Url.item result + result.modName = @getMod(result.modSlug).name return result findName: (slug, options={})-> diff --git a/src/jade/includes/_header.jade b/src/jade/includes/_header.jade index 1ac8ce3dc..dd38d7289 100644 --- a/src/jade/includes/_header.jade +++ b/src/jade/includes/_header.jade @@ -29,3 +29,5 @@ a(data-page="craft", href="/craft/") p Craft .dot + + .view__item_selector diff --git a/src/jade/templates/item_selector.jade b/src/jade/templates/item_selector.jade new file mode 100644 index 000000000..9086bc62c --- /dev/null +++ b/src/jade/templates/item_selector.jade @@ -0,0 +1,14 @@ +//- +//- Crafting Guide - item_selector.jade +//- +//- Copyright (c) 2015 by Redwood Labs +//- All rights reserved. +//- + +.view__item_selector + p.placeholder find items... + + .view__item_selector_popup.hideable.hiding + .search + input + .results \ No newline at end of file diff --git a/src/jade/templates/item_selector_element.jade b/src/jade/templates/item_selector_element.jade new file mode 100644 index 000000000..28f241f23 --- /dev/null +++ b/src/jade/templates/item_selector_element.jade @@ -0,0 +1,11 @@ +//- +//- Crafting Guide - item_selector_element.jade +//- +//- Copyright (c) 2015 by Redwood Labs +//- All rights reserved. +//- + +.view__item_selector_element + img + .name: p + .modName: p diff --git a/src/scss/classes.scss b/src/scss/classes.scss index e651ea904..455660243 100644 --- a/src/scss/classes.scss +++ b/src/scss/classes.scss @@ -130,6 +130,11 @@ All rights reserved. } } +.stop-scrolling { + height: 100%; + overflow: hidden; +} + .two-thirds-content { position: relative; width: 66%; margin: 5em 16.5%; diff --git a/src/scss/templates/index.scss b/src/scss/templates/index.scss index b009b69a3..ae6fa787e 100644 --- a/src/scss/templates/index.scss +++ b/src/scss/templates/index.scss @@ -17,6 +17,8 @@ All rights reserved. @import 'inventory_table'; @import 'item'; @import 'item_page'; +@import 'item_selector'; +@import 'item_selector_element'; @import 'login_page'; @import 'minimal_recipe'; @import 'mod'; diff --git a/src/scss/templates/item_selector.scss b/src/scss/templates/item_selector.scss new file mode 100644 index 000000000..6e8ec2aa2 --- /dev/null +++ b/src/scss/templates/item_selector.scss @@ -0,0 +1,51 @@ +/* +Crafting Guide - item_selector.scss + +Copyright (C) 2015 by Redwood Labs +All rights reserved. +*/ + +.view__item_selector { + background: $color-background-panel; + border: 0.1em solid $color-gray-medium; + border-radius: 1.5em; + cursor: pointer; + padding: 0 0.75em; + + p.placeholder { + position: relative; top: 50%; width: 100%; + @include transform(translateY(-50%)); + + color: $color-gray-medium; + font-family: $font-family-input; + font-size: $font-size-normal; + } +} + +.view__item_selector_popup { + position: absolute; top: 0; left: 0; + min-height: 5em; + + background: $color-background-panel; + border: 0.1em solid $color-gray-medium; + border-radius: 1.5em 1.5em 0 0; + box-shadow: 10px 10px 20px #666; + + .search { + border-bottom: 0.1em solid $color-gray-medium; + margin: 1em; + + input { + border: none; + outline: none; + width: 100%; + } + } + + .results { + position: relative; + max-height: 50em; + overflow-y: auto; + padding-bottom: 1em; + } +} diff --git a/src/scss/templates/item_selector_element.scss b/src/scss/templates/item_selector_element.scss new file mode 100644 index 000000000..fe85053a5 --- /dev/null +++ b/src/scss/templates/item_selector_element.scss @@ -0,0 +1,31 @@ +/* +Crafting Guide - item_selector_element.scss + +Copyright (C) 2015 by Redwood Labs +All rights reserved. +*/ + +.view__item_selector_element { + height: 3.6em; + padding: 0.3em 1em; + clear: both; + + transition: background $animate-fast, color $animate-fast; + + &.selected { + background: $color-active-hover; + color: $color-text-light; + } + + img { + height: 3.6em; width: 3.6em; + float: left; + margin-right: 1em; + } + + .modName p { + color: $color-gray-medium; + font-size: $font-size-small; + margin-top: 0.2em; + } +} diff --git a/src/scss/views.scss b/src/scss/views.scss index 7a48fea5c..1f95a0c50 100644 --- a/src/scss/views.scss +++ b/src/scss/views.scss @@ -132,6 +132,10 @@ All rights reserved. transition: color $animate-normal; } } + + .view__item_selector { + position: absolute; top: 0; right: 0; height: 3em; width: 35em; + } } } @@ -139,4 +143,4 @@ All rights reserved. position: fixed; top: 0; right: 0; bottom: 0; left: 0; display: none; z-index: $layer-screen; -} \ No newline at end of file +} diff --git a/test/inventory.test.coffee b/test/inventory.test.coffee index 2e9e2b7d2..2bef270b6 100644 --- a/test/inventory.test.coffee +++ b/test/inventory.test.coffee @@ -129,9 +129,9 @@ describe 'inventory.coffee', -> inventory.each (stack)-> slugs.push stack.itemSlug.qualified slugs.should.eql [ 'minecraft__boat' + 'buildcraft__stone_gear' 'minecraft__string' 'minecraft__wool' - 'buildcraft__stone_gear' ] it 'ignores qualified slugs', -> @@ -143,9 +143,9 @@ describe 'inventory.coffee', -> inventory.each (stack)-> slugs.push stack.itemSlug.qualified slugs.should.eql [ 'minecraft__boat' + 'buildcraft__stone_gear' 'minecraft__string' 'minecraft__wool' - 'buildcraft__stone_gear' ] describe 'parse', -> diff --git a/test/item_slug.test.coffee b/test/item_slug.test.coffee index 98dd326a2..2d942b040 100644 --- a/test/item_slug.test.coffee +++ b/test/item_slug.test.coffee @@ -35,20 +35,6 @@ describe 'item_slug.coffee', -> describe 'ItemSlug.compare', -> - it 'sorts qualified slugs first', -> - a = new ItemSlug 'alpha' - b = new ItemSlug 'bravo', 'charlie' - - ItemSlug.compare(a, b).should.equal +1 - ItemSlug.compare(b, a).should.equal -1 - - it 'sorts by mod when both are qualified', -> - a = new ItemSlug 'alpha', 'bravo' - b = new ItemSlug 'charlie', 'delta' - - ItemSlug.compare(a, b).should.equal -1 - ItemSlug.compare(b, a).should.equal +1 - it 'sorts by item when both are qualified in the same mod', -> a = new ItemSlug 'alpha', 'bravo' b = new ItemSlug 'charlie', 'bravo' diff --git a/test/parser_versions/mod_version_parser_v1.test.coffee b/test/parser_versions/mod_version_parser_v1.test.coffee index 979bc7131..064d85604 100644 --- a/test/parser_versions/mod_version_parser_v1.test.coffee +++ b/test/parser_versions/mod_version_parser_v1.test.coffee @@ -184,7 +184,7 @@ describe 'mod_version_parser_v1.coffee', -> it 'registers slugs for each output name', -> modVersion = parser.parse baseText + 'extras:Delta, Echo' (s.qualified for s in modVersion._slugs).should.eql [ - 'test__bravo', 'test__delta', 'charlie', 'echo' + 'test__bravo', 'charlie', 'test__delta', 'echo' ] it 'does not allow a duplicate "extras" declaration', ->