├── LICENSE ├── picker.html ├── picker-shortcodes.html ├── picker-ui.js ├── picker.js └── README.md /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Butterfree/Dragonfree/antialiasis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /picker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Picker 6 | 207 | 208 | 209 |

Picker

210 | 211 |

You can include any content you want here.

212 | 213 |
214 | 215 |
216 | 218 | 219 |

220 |
221 | 222 |
223 |

Found favorites

224 | 225 |
    226 |
227 |
228 |
229 | 230 | 231 | 232 | 233 | 276 | 277 | 278 | -------------------------------------------------------------------------------- /picker-shortcodes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Picker 6 | 273 | 274 | 275 |

Picker

276 | 277 |

You can include any content you want here.

278 | 279 |
280 | 281 |
282 | 284 | 285 |

286 |
287 | 288 |
289 |

Found favorites

290 | 291 |
    292 |
293 | 294 |

Permalink to this list

295 |
296 |
297 | 298 | 299 | 300 | 301 | 302 | 313 | 361 | 362 | 363 | -------------------------------------------------------------------------------- /picker-ui.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (root, factory) { 4 | if (typeof define === 'function' && define.amd) { 5 | define(['jquery'], factory); 6 | } else if (typeof module === 'object' && module.exports) { 7 | module.exports = factory(require('jquery')); 8 | } else { 9 | root.PickerUI = factory(root.jQuery); 10 | } 11 | }(typeof self !== 'undefined' ? self : this, function ($) { 12 | function PickerUI(picker, options) { 13 | var self = this; 14 | 15 | this.picker = picker; 16 | this.options = options; 17 | 18 | /* MESSAGE OPTIONS */ 19 | 20 | this.messages = $.extend({ 21 | reset: "Reset", 22 | mustSelect: "You must select something first! If you're indifferent, press Pass.", 23 | orderedAll: "You have ordered every available item!", 24 | noItems: "There are no items that fit your criteria! Set some different options and try again.", 25 | resetWarning: "Are you sure you wish to reset your state? All your found favorites and current progress will be lost." 26 | }, this.options.messages); 27 | 28 | /* MUTABLE UI STATE */ 29 | 30 | this.canPick = true; 31 | 32 | /* UI ELEMENTS */ 33 | 34 | this.elem = jquerify(options.elements || {}); 35 | if (!this.elem.settings) { 36 | this.elem.settings = {}; 37 | } 38 | 39 | /* EVENT HANDLERS FOR SETTINGS */ 40 | 41 | for (var key in this.elem.settings) { 42 | if (this.elem.settings.hasOwnProperty(key)) { 43 | this.elem.settings[key].on('change', function() { 44 | self.picker.setSettings(self.getSettings()); 45 | self.update(true); 46 | }); 47 | } 48 | } 49 | 50 | /* PICKER UI EVENT HANDLERS */ 51 | 52 | this.elem.evaluating.on('click', '.item', function(e) { 53 | e.preventDefault(); 54 | self.select(this); 55 | }).on('dblclick', '.item', function(e) { 56 | // Prevent double-clicking from selecting the current item if some other items have been selected 57 | e.preventDefault(); 58 | var selected = self.getSelected(); 59 | var item = self.getItem(this); 60 | if (selected.length === 0 || selected.length === 1 && selected[0] === item) { 61 | self.pick([item]); 62 | } 63 | }).on('mousedown', '.item', function(e) { 64 | e.preventDefault(); 65 | }); 66 | 67 | this.elem.pick.on('click', function(e) { 68 | e.preventDefault(); 69 | var selected = self.getSelected(); 70 | if (selected.length === 0) { 71 | alert(self.messages.mustSelect); 72 | } 73 | else { 74 | self.pick(selected); 75 | } 76 | }); 77 | 78 | this.elem.pass.on('click', function(e) { 79 | e.preventDefault(); 80 | self.pass(); 81 | }); 82 | 83 | this.elem.undo.on('click', function(e) { 84 | e.preventDefault(); 85 | self.undo(); 86 | }); 87 | 88 | this.elem.redo.on('click', function(e) { 89 | e.preventDefault(); 90 | self.redo(); 91 | }); 92 | 93 | if (this.elem.reset) { 94 | this.elem.reset.on('click', function(e) { 95 | e.preventDefault(); 96 | self.reset(); 97 | }); 98 | } 99 | 100 | if (this.elem.sharedListContinue) { 101 | this.elem.sharedListContinue.on('click', function(e) { 102 | e.preventDefault(); 103 | self.picker.resetToFavorites($.map(self.picker.getSharedFavorites(), function(item) { return item.id; })); 104 | console.log(self.picker.getSettings()); 105 | self.setSettings(self.picker.getSettings()); 106 | self.update(true); 107 | self.dismissSharedList(); 108 | }); 109 | } 110 | 111 | if (this.elem.sharedListSkip) { 112 | this.elem.sharedListSkip.on('click', function(e) { 113 | e.preventDefault(); 114 | self.dismissSharedList(); 115 | }); 116 | } 117 | 118 | function jquerify(obj) { 119 | var result = {} 120 | for (var key in obj) { 121 | if (key === 'settings') { 122 | result[key] = jquerify(obj[key]); 123 | } 124 | else if (obj.hasOwnProperty(key)) { 125 | result[key] = $(obj[key]); 126 | } 127 | } 128 | return result; 129 | } 130 | 131 | return this; 132 | } 133 | 134 | /* INITIALIZATION */ 135 | 136 | PickerUI.prototype.initialize = function() { 137 | /** 138 | * Initializes UI. 139 | */ 140 | this.setSettings(this.picker.getSettings()); 141 | this.update(); 142 | 143 | var sharedFavorites = this.picker.getSharedFavorites(); 144 | if (sharedFavorites) { 145 | this.displaySharedList(sharedFavorites); 146 | } 147 | }; 148 | 149 | /* GETTERS/SETTERS FOR SETTINGS */ 150 | 151 | PickerUI.prototype.getSetting = function(setting) { 152 | var $elem = $(this.elem.settings[setting]); 153 | var type = $elem.attr("type"); 154 | var values; 155 | var value; 156 | 157 | if (type === 'checkbox' || type === 'radio') { 158 | if ($elem.length === 1) { 159 | return $elem.prop("checked"); 160 | } 161 | else { 162 | values = $.makeArray($elem.filter(":checked").map(function () { 163 | var value = this.value; 164 | if ($(this).hasClass("setting-number")) { 165 | value *= 1; 166 | } 167 | return value; 168 | })); 169 | if (type === 'checkbox') { 170 | return values; 171 | } else { 172 | return values[0]; 173 | } 174 | } 175 | } 176 | else { 177 | value = $elem.val(); 178 | if (type === 'number' || $(this).hasClass("setting-number")) { 179 | value *= 1; 180 | } 181 | return value; 182 | } 183 | }; 184 | 185 | PickerUI.prototype.setSetting = function(setting, value) { 186 | var $elem = $(this.elem.settings[setting]); 187 | var type = $elem.attr("type"); 188 | 189 | if (type === 'checkbox' || type === 'radio') { 190 | if ($elem.length === 1) { 191 | $elem.prop("checked", value); 192 | } 193 | else { 194 | $elem.each(function () { 195 | var val = this.value; 196 | if ($(this).hasClass("setting-number")) { 197 | val *= 1; 198 | } 199 | $(this).prop("checked", $.isArray(value) ? value.indexOf(val) !== -1 : value === val); 200 | }); 201 | } 202 | } 203 | else { 204 | if (type === 'number' || $(this).hasClass("setting-number")) { 205 | value *= 1; 206 | } 207 | $elem.val(value); 208 | } 209 | }; 210 | 211 | PickerUI.prototype.getSettings = function() { 212 | var settings = {}; 213 | var setting; 214 | 215 | for (setting in this.elem.settings) { 216 | if (this.elem.settings.hasOwnProperty(setting)) { 217 | settings[setting] = this.getSetting(setting); 218 | } 219 | } 220 | return settings; 221 | }; 222 | 223 | PickerUI.prototype.setSettings = function(settings) { 224 | var setting; 225 | 226 | for (setting in settings) { 227 | if (settings.hasOwnProperty(setting)) { 228 | this.setSetting(setting, settings[setting]); 229 | } 230 | } 231 | }; 232 | 233 | /* SELECTION */ 234 | 235 | PickerUI.prototype.select = function(elem) { 236 | /** 237 | * Selects the given element. 238 | */ 239 | $(elem).toggleClass("selected"); 240 | }; 241 | 242 | PickerUI.prototype.getItem = function(elem) { 243 | /** 244 | * Gets the item associated with this element. 245 | */ 246 | return $(elem).data('item'); 247 | }; 248 | 249 | PickerUI.prototype.getSelected = function() { 250 | /** 251 | * Returns a list of the currently selected items. 252 | */ 253 | var self = this; 254 | return this.elem.evaluating.find(".selected").map(function(i, item) { 255 | return self.getItem(this); 256 | }).get(); 257 | }; 258 | 259 | /* UI UPDATE FUNCTIONS */ 260 | 261 | PickerUI.prototype.display = function(func, quick) { 262 | /** 263 | * If quick is true, run the given display func immediately. 264 | * Otherwise, fade out the item list, run it, then fade in 265 | * again. 266 | */ 267 | var self = this; 268 | 269 | if (quick) { 270 | func(); 271 | } 272 | else { 273 | this.elem.evaluating.animate({opacity: 0}, 'fast', function() { 274 | func(); 275 | self.elem.evaluating.animate({opacity: 1}, 'fast'); 276 | }); 277 | } 278 | }; 279 | 280 | PickerUI.prototype.displayEmpty = function() { 281 | /** 282 | * Displays an empty message. 283 | */ 284 | var item = this.wrapItem((this.picker.hasItems() ? (this.messages.orderedAll + ' ') : this.messages.noItems)).addClass("notice"); 285 | if (this.picker.hasItems()) { 286 | item.append(this.makeResetButton(this.messages.reset + '?')); 287 | } 288 | this.elem.evaluating.empty().width('100%').append(item); 289 | this.updatePickPass(false); 290 | }; 291 | 292 | PickerUI.prototype.displayBatch = function() { 293 | /** 294 | * Displays the current evaluating batch of items. 295 | */ 296 | var self = this; 297 | var batch = this.picker.getEvaluating(); 298 | this.elem.evaluating.empty(); 299 | $.each(batch, function() { 300 | self.elem.evaluating.append(self.getItemElem(this, self.picker.getSettings())); 301 | }); 302 | this.updatePickPass(true); 303 | }; 304 | 305 | PickerUI.prototype.updateHistoryButtons = function() { 306 | /** 307 | * Updates the undo/redo buttons based on the state. 308 | */ 309 | this.elem.undo.toggleClass("disabled", !this.picker.canUndo()); 310 | this.elem.redo.toggleClass("disabled", !this.picker.canRedo()); 311 | }; 312 | 313 | PickerUI.prototype.updatePickPass = function(canPick) { 314 | /** 315 | * Enables/disables the pick/pass buttons based on canPick. 316 | */ 317 | this.elem.pick.toggleClass("disabled", !canPick).prop("disabled", !canPick); 318 | this.elem.pass.toggleClass("disabled", !canPick).prop("disabled", !canPick); 319 | }; 320 | 321 | PickerUI.prototype.updateFavorites = function() { 322 | /** 323 | * Update the found favorites list according to the state. 324 | */ 325 | var self = this; 326 | var favorites = this.picker.getFavorites(); 327 | this.elem.favorites.empty(); 328 | $.each(favorites, function() { 329 | self.elem.favorites.append(self.getItemElem(this, self.picker.getSettings())); 330 | }); 331 | if (this.elem.shortcodeLink && this.picker.options.favoritesQueryParam && this.picker.options.shortcodeLength) { 332 | this.elem.shortcodeLink.attr('href', this.picker.getShortcodeLink()).toggle(favorites.length > 0); 333 | } 334 | }; 335 | 336 | PickerUI.prototype.update = function(quick) { 337 | /** 338 | * Perform a full UI update based on the current state. The update is 339 | * immediate if quick is true; otherwise, the Pokémon display will be 340 | * faded out/in. 341 | */ 342 | var self = this; 343 | 344 | this.display(function() { 345 | if (self.picker.getEvaluating().length === 0) { 346 | self.displayEmpty(); 347 | } 348 | else { 349 | self.displayBatch() 350 | } 351 | self.updateFavorites(); 352 | if (self.options.onUpdate) { 353 | self.options.onUpdate.call(self); 354 | } 355 | self.canPick = true; 356 | }, quick); 357 | this.updateHistoryButtons(); 358 | }; 359 | 360 | PickerUI.prototype.dismissSharedList = function() { 361 | /** 362 | * Dismiss a shared list. 363 | */ 364 | if (this.options.dismissSharedList) { 365 | return this.options.dismissSharedList.call(this); 366 | } 367 | if (history.replaceState) { 368 | history.replaceState({}, document.title, window.location.pathname); 369 | } 370 | this.elem.sharedListContainer.hide(); 371 | }; 372 | 373 | PickerUI.prototype.displaySharedList = function(favorites) { 374 | /** 375 | * Display the given favorites as a shared list. 376 | */ 377 | if (this.options.displaySharedList) { 378 | return this.options.displaySharedList.call(this, favorites); 379 | } 380 | var self = this; 381 | 382 | if (!self.elem.sharedList || !self.elem.sharedListContainer) return; 383 | 384 | $.each(favorites, function() { 385 | self.elem.sharedList.append(self.getItemElem(this, self.picker.getSettings())); 386 | }); 387 | 388 | this.elem.sharedListContainer.show(); 389 | }; 390 | 391 | /* MAIN PICKER FUNCTIONALITY */ 392 | 393 | PickerUI.prototype.pick = function(items) { 394 | /** 395 | * Pick the given items. 396 | */ 397 | if (!this.canPick) return; 398 | this.canPick = false; 399 | this.picker.pick(items); 400 | this.update(); 401 | }; 402 | 403 | PickerUI.prototype.pass = function() { 404 | /** 405 | * Pass on this batch. 406 | */ 407 | if (!this.canPick) return; 408 | this.canPick = false; 409 | this.picker.pass(); 410 | this.update(); 411 | }; 412 | 413 | PickerUI.prototype.undo = function() { 414 | /** 415 | * Undo the last action. 416 | */ 417 | if (this.picker.canUndo()) { 418 | this.picker.undo(); 419 | this.setSettings(this.picker.getSettings()); 420 | this.update(); 421 | } 422 | }; 423 | 424 | PickerUI.prototype.redo = function() { 425 | /** 426 | * Redo the last undone action. 427 | */ 428 | if (this.picker.canRedo()) { 429 | this.picker.redo(); 430 | this.setSettings(this.picker.getSettings()); 431 | this.update(); 432 | } 433 | }; 434 | 435 | PickerUI.prototype.reset = function() { 436 | /** 437 | * Reset the state (prompting if the state is not untouched). 438 | */ 439 | var untouched = this.picker.isUntouched(); 440 | if (untouched || confirm(this.messages.resetWarning)) { 441 | this.picker.reset(); 442 | this.update(); 443 | } 444 | }; 445 | 446 | /* UI UTILITY FUNCTIONS */ 447 | 448 | PickerUI.prototype.wrapItem = function(itemContent) { 449 | /** 450 | * Wraps the given item content in an HTML structure and returns it. 451 | */ 452 | if (this.options.wrapItem) { 453 | return $(this.options.wrapItem(itemContent)); 454 | } 455 | return $('
  • ').append(itemContent); 456 | }; 457 | 458 | PickerUI.prototype.getItemElem = function(item, settings) { 459 | /** 460 | * Creates and returns an element or jQuery object for an item, 461 | * to be inserted into the evaluating element. 462 | * The behaviour of this function can be overridden with the 463 | * getItemElem setting. By default, if the getImageUrl setting 464 | * is set, it returns an image with that URL; otherwise, it simply 465 | * returns a plain text list item. 466 | */ 467 | var itemContent; 468 | var itemName; 469 | itemName = item.name || item.id; 470 | if (this.options.getItemElem) { 471 | return $(this.options.getItemElem(item, settings)).addClass('item').data('item', item.id); 472 | } 473 | if (item.image || this.options.getItemImageUrl) { 474 | itemContent = $('' + itemName + ''); 475 | } 476 | else { 477 | itemContent = $('' + itemName + ''); 478 | } 479 | return this.wrapItem(itemContent).addClass('item').data('item', item.id); 480 | }; 481 | 482 | PickerUI.prototype.makeResetButton = function(text) { 483 | /** 484 | * Creates and returns a reset button. 485 | */ 486 | var self = this; 487 | return $('').on('click', function() { 488 | self.reset(); 489 | }); 490 | }; 491 | 492 | return PickerUI; 493 | })); 494 | -------------------------------------------------------------------------------- /picker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (root, factory) { 4 | if (typeof define === 'function' && define.amd) { 5 | define([], factory); 6 | } else if (typeof module === 'object' && module.exports) { 7 | module.exports = factory(); 8 | } else { 9 | root.picker = factory(); 10 | } 11 | }(typeof self !== 'undefined' ? self : this, function () { 12 | /* POLYFILLS */ 13 | 14 | if (!Array.isArray) { 15 | Array.isArray = function(arg) { 16 | return Object.prototype.toString.call(arg) === '[object Array]'; 17 | }; 18 | } 19 | 20 | /* PICKER STATE OBJECT */ 21 | 22 | function PickerState(options) { 23 | if (!options.items) { 24 | console.error("No items specified for PickerState!"); 25 | return; 26 | } 27 | this.options = copyObject(options); 28 | }; 29 | 30 | /* INITIALIZATION AND SERIALIZATION */ 31 | 32 | PickerState.prototype.getState = function() { 33 | /** 34 | * Returns a state object corresponding to this PickerState. 35 | * We're using deep copies because otherwise the eliminatedBy arrays 36 | * may get mutated and undoing/redoing can corrupt the state. 37 | */ 38 | return { 39 | eliminated: copyArray(this.arrays.eliminated), 40 | survived: copyArray(this.arrays.survived), 41 | current: copyArray(this.arrays.current), 42 | evaluating: copyArray(this.arrays.evaluating), 43 | favorites: copyArray(this.arrays.favorites), 44 | settings: copyObject(this.settings) 45 | }; 46 | }; 47 | 48 | PickerState.prototype.initialize = function(settings) { 49 | /** 50 | * Initializes the PickerState according to the given settings 51 | * (or the default settings if no settings are provided). 52 | */ 53 | this.settings = settings || this.options.defaultSettings || {}; 54 | this.items = this.getFilteredItems(); 55 | 56 | this.arrays = { 57 | eliminated: [], 58 | survived: [], 59 | current: this.items.slice(0), 60 | evaluating: [], 61 | favorites: [] 62 | }; 63 | this.batchSize = this.getBatchSize(this.arrays.current.length); 64 | 65 | shuffle(this.arrays.current); 66 | 67 | this.nextBatch(); 68 | }; 69 | 70 | PickerState.prototype.restoreState = function(state) { 71 | /** 72 | * Sets the PickerState to the given dehydrated state. 73 | */ 74 | this.settings = copyObject(this.options.defaultSettings || {}, state.settings || {}); 75 | 76 | this.items = this.getFilteredItems(); 77 | 78 | this.arrays = { 79 | eliminated: copyArray(state.eliminated), 80 | survived: copyArray(state.survived), 81 | current: copyArray(state.current), 82 | evaluating: copyArray(state.evaluating), 83 | favorites: copyArray(state.favorites) 84 | }; 85 | this.batchSize = this.arrays.evaluating.length; 86 | 87 | this.validate(); 88 | }; 89 | 90 | PickerState.prototype.reset = function() { 91 | /** 92 | * Resets the PickerState to its initial state (leaving the settings 93 | * unchanged). 94 | */ 95 | this.initialize(this.settings); 96 | }; 97 | 98 | /* PUBLIC SETTERS */ 99 | 100 | PickerState.prototype.setSettings = function(settings) { 101 | /** 102 | * Sets the settings. 103 | */ 104 | this.settings = settings; 105 | this.items = this.getFilteredItems(); 106 | 107 | this.validate(); 108 | this.resetBatchSize(); 109 | }; 110 | 111 | PickerState.prototype.setFavorites = function(favorites) { 112 | /** 113 | * Overwrites the found favorites list with the given one. 114 | * Since it runs validate, it should be fine if this changes the 115 | * actual contents of the list. 116 | */ 117 | this.arrays.favorites = favorites; 118 | this.validate(); 119 | }; 120 | 121 | /* STATE UTILITY FUNCTIONS */ 122 | 123 | PickerState.prototype.findByIdentifier = function(identifier, array) { 124 | /** 125 | * Searches for the given item identifier in the given array and 126 | * returns the index at which that identifier is found (or -1 if it is 127 | * not found). Handles both plain arrays of identifiers and arrays of 128 | * objects with an id property (e.g. the eliminated array). 129 | */ 130 | for (var i = 0; i < array.length; i++) { 131 | if (array[i] === identifier || array[i].id === identifier) { 132 | return i; 133 | } 134 | } 135 | return -1; 136 | }; 137 | 138 | PickerState.prototype.shouldIncludeItem = function(identifier, settings) { 139 | /** 140 | * Returns true if this item should be included in the picker 141 | * according to the current settings. 142 | */ 143 | if (this.options.getFilteredItems) { 144 | return this.options.getFilteredItems(settings).indexOf(identifier) !== -1; 145 | } 146 | else if (this.options.shouldIncludeItem) { 147 | return this.options.shouldIncludeItem(identifier, settings); 148 | } 149 | return true; 150 | }; 151 | 152 | PickerState.prototype.getFilteredItems = function() { 153 | /** 154 | * Returns a list of item identifiers that match the given 155 | * settings. 156 | */ 157 | if (this.options.getFilteredItems) { 158 | return this.options.getFilteredItems(this.settings); 159 | } 160 | var result = []; 161 | var i; 162 | for (i = 0; i < this.options.items.length; i++) { 163 | if (this.shouldIncludeItem(this.options.items[i], this.settings)) { 164 | result.push(this.options.items[i]); 165 | } 166 | } 167 | return result; 168 | }; 169 | 170 | PickerState.prototype.findInArray = function(identifier, arrayName) { 171 | /** 172 | * If the given identifier is found in the given array of the state, 173 | * return that entry. Otherwise, return null. 174 | */ 175 | var index = this.findByIdentifier(identifier, this.arrays[arrayName]); 176 | if (index !== -1) { 177 | return this.arrays[arrayName][index]; 178 | } 179 | else { 180 | return null; 181 | } 182 | }; 183 | 184 | PickerState.prototype.getBatchSize = function(currentSize) { 185 | /** 186 | * Returns the number of items that should ideally be displayed at a 187 | * time, given the whole round is currentSize items. 188 | */ 189 | if (this.options.getBatchSize) { 190 | return this.options.getBatchSize(currentSize, this.settings); 191 | } 192 | return Math.max(2, this.settings.minBatchSize || 2, Math.min(this.settings.maxBatchSize || 20, Math.ceil(currentSize / 5))); 193 | }; 194 | 195 | PickerState.prototype.resetBatchSize = function() { 196 | /** 197 | * Resets the current batch size to whatever it ought to be given the 198 | * size of the current and survived arrays and adjusts the evaluating 199 | * array accordingly. 200 | */ 201 | Array.prototype.unshift.apply(this.arrays.current, this.arrays.evaluating); 202 | this.arrays.evaluating = this.arrays.current.splice(0, this.getBatchSize(this.arrays.current.length + this.arrays.survived.length)); 203 | this.batchSize = this.arrays.evaluating.length; 204 | }; 205 | 206 | /* STATE VALIDATION */ 207 | 208 | PickerState.prototype.validate = function () { 209 | /** 210 | * Validates and corrects the state. 211 | */ 212 | var expectedItems = this.getFilteredItems(); 213 | 214 | var missingItems = []; 215 | var extraItems = []; 216 | var survived = this.arrays.survived; 217 | var eliminated = this.arrays.eliminated; 218 | var evaluating = this.arrays.evaluating; 219 | var current = this.arrays.current; 220 | var favorites = this.arrays.favorites; 221 | var arrays = [favorites, survived, eliminated, current, evaluating]; 222 | var identifier; 223 | 224 | var verifyObject = {}; 225 | var i, j; 226 | 227 | for (i = 0; i < expectedItems.length; i++) { 228 | verifyObject[expectedItems[i]] = false; 229 | } 230 | 231 | // Go through all the items in each array and: 232 | // - correct errors 233 | // - mark off the item in the verify object 234 | // - make sure that each item appears only once by checking if it's 235 | // previously been marked off 236 | // - remove any extra items that shouldn't be there 237 | // We do this backwards so that we can remove items with splice 238 | // without messing up the parts of the array we haven't gone through 239 | // yet. 240 | for (i = 0; i < arrays.length; i++) { 241 | for (j = arrays[i].length - 1; j >= 0; j--) { 242 | identifier = arrays[i][j].id || arrays[i][j]; 243 | if (identifier in verifyObject) { 244 | // This is one of the items we expect 245 | if (verifyObject[identifier]) { 246 | // We've already found this item - it's a copy. 247 | // Remove it from this array and restore any items 248 | // eliminated by it, since it might be in error. 249 | arrays[i].splice(j, 1); 250 | this.removeFromEliminated(identifier); 251 | } 252 | verifyObject[identifier] = true; 253 | } 254 | else { 255 | // This is an unexpected item - we want to remove it 256 | arrays[i].splice(j, 1); 257 | extraItems.push(identifier); 258 | } 259 | } 260 | } 261 | // Ensure no item is eliminated by itself, fix eliminated items not 262 | // being properly ntroduced after their eliminator is found, plus 263 | // removing extraneous items from eliminated lists. 264 | // We go through both arrays backwards so that splicing the indices 265 | // won't mess up subsequent indices. 266 | for (i = eliminated.length - 1; i >= 0; i--) { 267 | for (j = eliminated[i].eliminatedBy.length - 1; j >= 0; j--) { 268 | if (eliminated[i].id === eliminated[i].eliminatedBy[j]) { 269 | this.removeEliminatedBy(i, j); 270 | } 271 | if (favorites.indexOf(eliminated[i].eliminatedBy[j]) !== -1 || extraItems.indexOf(eliminated[i].eliminatedBy[j]) !== -1) { 272 | this.removeEliminatedBy(i, j); 273 | } 274 | } 275 | } 276 | 277 | // Add in any items that we ought to have but weren't in any of the 278 | // arrays 279 | for (identifier in verifyObject) { 280 | if (verifyObject[identifier] === false) { 281 | missingItems.push(identifier); 282 | current.push(identifier); 283 | } 284 | } 285 | 286 | // Store the missing items that we've added, if we want to alert the 287 | // user about them later 288 | if (missingItems.length > 0) { 289 | this.missingItems = missingItems; 290 | // Shuffle current: if we've just added some items, we don't want 291 | // them all to be dumped at the end of the round 292 | shuffle(current); 293 | } 294 | 295 | if (current.length === 0 && evaluating.length === 0 && survived.length > 0) { 296 | this.nextRound(); 297 | return; 298 | } 299 | 300 | if (evaluating.length < 2) { 301 | // Give us an evaluation batch of the size that it should be. 302 | this.resetBatchSize(); 303 | } 304 | else { 305 | this.batchSize = evaluating.length; 306 | } 307 | }; 308 | 309 | /* MAIN PICKER LOGIC */ 310 | 311 | PickerState.prototype.pick = function(picked) { 312 | /** 313 | * Picks the given items from the current evaluating batch, moving 314 | * them into the survived array and the others into the eliminated 315 | * array. 316 | */ 317 | var i; 318 | var evaluating = this.arrays.evaluating; 319 | var survived = this.arrays.survived; 320 | var eliminated = this.arrays.eliminated; 321 | 322 | // Loop through the items we're currently evaluating 323 | for (i = 0; i < evaluating.length; i++) { 324 | if (!picked.length || this.findByIdentifier(evaluating[i], picked) !== -1) { 325 | // This item is one of the ones we picked - add it to 326 | // survived 327 | survived.push(evaluating[i]); 328 | } 329 | else { 330 | // This item is not one of the ones we picked - add it to 331 | // eliminated, with the picked items as the eliminators 332 | eliminated.push({id: evaluating[i], eliminatedBy: picked.slice(0)}); 333 | } 334 | } 335 | this.arrays.evaluating = []; 336 | this.nextBatch(); 337 | }; 338 | 339 | PickerState.prototype.pass = function() { 340 | /** 341 | * Passes on this batch of items, equivalent to picking every 342 | * item. 343 | */ 344 | this.pick(this.arrays.evaluating); 345 | }; 346 | 347 | PickerState.prototype.removeEliminatedBy = function(i, j) { 348 | /** 349 | * Removes the jth item from the eliminatedBy array of the ith 350 | * item in the eliminated array, restoring the item to the 351 | * survived array if this leaves the eliminatedBy list empty. 352 | * 353 | * This modifies the arrays in-place; if executed inside a loop, 354 | * the loop must run backwards through both arrays. 355 | */ 356 | var eliminated = this.arrays.eliminated; 357 | 358 | eliminated[i].eliminatedBy.splice(j, 1); 359 | if (eliminated[i].eliminatedBy.length === 0) { 360 | this.arrays.survived.push(eliminated.splice(i, 1)[0].id); 361 | } 362 | }; 363 | 364 | PickerState.prototype.removeFromEliminated = function(item) { 365 | /** 366 | * Remove this item from all eliminatedBy lists, restoring any 367 | * items left with empty eliminatedBy lists to the survived array. 368 | */ 369 | var i, idx; 370 | var eliminated = this.arrays.eliminated; 371 | 372 | // Find items that were eliminated by this item. 373 | for (i = eliminated.length - 1; i >= 0; i--) { 374 | idx = this.findByIdentifier(item, eliminated[i].eliminatedBy); 375 | if (idx !== -1) { 376 | // This item was (partly) eliminated by the given item; 377 | // remove it 378 | this.removeEliminatedBy(i, idx); 379 | } 380 | } 381 | }; 382 | 383 | PickerState.prototype.addToFavorites = function(item) { 384 | /** 385 | * Add the given item (identifier) to favorites and restore 386 | * the items eliminated by it to survived. 387 | */ 388 | this.arrays.favorites.push(item); 389 | this.removeFromEliminated(item); 390 | }; 391 | 392 | PickerState.prototype.nextBatch = function() { 393 | /** 394 | * Moves on to the next batch of items, adding to favorites if appropriate. 395 | */ 396 | var current = this.arrays.current; 397 | 398 | if (current.length < this.batchSize && this.arrays.survived.length > 0) { 399 | // Start the next round 400 | this.nextRound(); 401 | return; 402 | } 403 | this.arrays.evaluating = current.splice(0, this.batchSize); 404 | }; 405 | 406 | PickerState.prototype.nextRound = function() { 407 | /** 408 | * Moves on to the next round, shuffling the survived array back into 409 | * the current array. 410 | */ 411 | // If we've only got one item left in survived, then it's our next 412 | // favorite - add it to favorites and then start the next round with 413 | // the new survivors. 414 | if (this.arrays.current.length === 0 && this.arrays.survived.length === 1) { 415 | this.addToFavorites(this.arrays.survived.pop()); 416 | this.nextRound(); 417 | return; 418 | } 419 | shuffle(this.arrays.survived); 420 | // Take the survivors and put them at the end of the current array. 421 | this.arrays.current = this.arrays.current.concat(this.arrays.survived.splice(0, this.arrays.survived.length)); 422 | // Pick an appropriate batch size for this new round and then show the next batch. 423 | this.batchSize = this.getBatchSize(this.arrays.current.length); 424 | this.nextBatch(); 425 | }; 426 | 427 | /* PICKER OBJECT */ 428 | 429 | function Picker(options) { 430 | if (!(this instanceof Picker)) { 431 | return new Picker(options); 432 | } 433 | 434 | if (!options.items) { 435 | console.error("No items specified for picker."); 436 | return; 437 | } 438 | 439 | var self = this; 440 | 441 | this.itemMap = {}; 442 | this.options = copyObject({ 443 | historyLength: 3, 444 | favoritesQueryParam: 'favs' 445 | }, options); 446 | 447 | this.history = []; 448 | this.historyPos = -1; 449 | 450 | var i; 451 | 452 | // Build the itemMap and catch errors 453 | for (i = 0; i < options.items.length; i++) { 454 | if (options.items[i].id === undefined) { 455 | console.error("You have an item without an ID! An ID is necessary for the picker's functionality to work.", options.items[i]); 456 | return; 457 | } 458 | if (this.itemMap.hasOwnProperty(options.items[i].id)) { 459 | console.error("You have more than one item with the same ID (" + options.items[i].id + ")! Please ensure the IDs of your items are unique."); 460 | return; 461 | } 462 | if (options.shortcodeLength && (!options.items[i].shortcode || options.items[i].shortcode.length !== options.shortcodeLength)) { 463 | console.error("You have defined a shortcode length of " + options.shortcodeLength + "; however, you have an item with a shortcode that does not match this length (" + options.items[i].shortcode + "). The shortcode functionality only works if the item shortcodes are of a consistent length.", options.items[i]); 464 | return; 465 | } 466 | this.itemMap[options.items[i].id] = options.items[i]; 467 | } 468 | 469 | var defaultSettings = options.defaultSettings || {}; 470 | 471 | 472 | /* PICKER INITIALIZATION */ 473 | 474 | var pickerStateOptions = { 475 | items: map(options.items, function (item) { 476 | return item.id; 477 | }), 478 | getBatchSize: options.getBatchSize, 479 | shouldIncludeItem: options.shouldIncludeItem && function (identifier, settings) { 480 | return options.shouldIncludeItem(self.itemMap[identifier], settings) 481 | }, 482 | getFilteredItems: options.getFilteredItems, 483 | defaultSettings: defaultSettings 484 | }; 485 | 486 | var savedState = this.loadState(); 487 | 488 | // Modify the savedState if we have a modifyState function... 489 | if (savedState && options.modifyState) { 490 | savedState = options.modifyState(savedState); 491 | } 492 | // ...but if the end result isn't a valid state, throw it away 493 | if (savedState && !isState(savedState)) { 494 | console.warn("Ignoring invalid saved state"); 495 | savedState = null; 496 | } 497 | 498 | this.state = new PickerState(pickerStateOptions); 499 | 500 | if (savedState) { 501 | this.state.restoreState(savedState, defaultSettings); 502 | if (options.onLoadState) { 503 | options.onLoadState.call( 504 | this, 505 | this.state.missingItems || [], 506 | this.state.extraItems || [] 507 | ); 508 | } 509 | this.pushHistory(); 510 | } 511 | else { 512 | this.state.initialize(defaultSettings); 513 | this.pushHistory(); 514 | } 515 | } 516 | 517 | /* GETTERS */ 518 | 519 | Picker.prototype.getArray = function(arrayName) { 520 | /** 521 | * Gets the full list of items in the given array. 522 | */ 523 | return this.mapItems(this.state.arrays[arrayName]); 524 | }; 525 | 526 | Picker.prototype.getFavorites = function() { 527 | /** 528 | * Gets the current favorite list. 529 | */ 530 | return this.getArray('favorites'); 531 | }; 532 | 533 | Picker.prototype.getEvaluating = function() { 534 | /** 535 | * Gets the current evaluating list. 536 | */ 537 | return this.getArray('evaluating'); 538 | }; 539 | 540 | Picker.prototype.getSettings = function() { 541 | /** 542 | * Gets the state's current settings. 543 | */ 544 | return this.state.settings; 545 | }; 546 | 547 | Picker.prototype.getSharedFavorites = function() { 548 | /** 549 | * Gets the shared favorite list. 550 | */ 551 | var query; 552 | 553 | if (window.location.search && this.options.favoritesQueryParam && this.options.shortcodeLength) { 554 | query = parseQueryString(window.location.search.substring(1)); 555 | return this.mapItems(this.parseShortcodeString(query[this.options.favoritesQueryParam]) || []); 556 | } 557 | return null; 558 | }; 559 | 560 | /* SHORTCODES */ 561 | 562 | Picker.prototype.getShortcodeString = function() { 563 | /** 564 | * Gets a shortcode string for the current favorite list. 565 | */ 566 | return map(this.getFavorites(), function(item) { 567 | return item.shortcode; 568 | }).join(''); 569 | }; 570 | 571 | Picker.prototype.getShortcodeLink = function() { 572 | /** 573 | * Gets a shortcode link for the current favorite list. 574 | */ 575 | return '?' + this.options.favoritesQueryParam + '=' + this.getShortcodeString(); 576 | }; 577 | 578 | Picker.prototype.parseShortcodeString = function(shortcodeString) { 579 | /** 580 | * Returns the list of favorites given by a shortcode string. 581 | */ 582 | var self = this; 583 | var favorites = []; 584 | var i; 585 | var shortcode; 586 | var shortcodeMap = {}; 587 | var favoriteMap = {}; 588 | 589 | this.forEachItem(function (identifier) { 590 | shortcodeMap[self.itemMap[identifier].shortcode] = identifier; 591 | }); 592 | 593 | for (i = 0; i < shortcodeString.length; i += this.options.shortcodeLength) { 594 | shortcode = shortcodeString.substring(i, i + this.options.shortcodeLength); 595 | if (shortcode in shortcodeMap) { 596 | if (!favoriteMap[shortcodeMap[shortcode]]) { 597 | favorites.push(shortcodeMap[shortcode]); 598 | favoriteMap[shortcodeMap[shortcode]] = true; 599 | } 600 | } 601 | } 602 | return favorites; 603 | }; 604 | 605 | /* HISTORY */ 606 | 607 | Picker.prototype.pushHistory = function() { 608 | /** 609 | * Adds the current state to the history array. 610 | */ 611 | this.history.splice(this.historyPos + 1, this.history.length, this.state.getState()); 612 | if (this.history.length > this.options.historyLength + 1) { 613 | this.history.shift(); 614 | } 615 | this.historyPos = this.history.length - 1; 616 | this.saveState(); 617 | }; 618 | 619 | Picker.prototype.canUndo = function() { 620 | /** 621 | * Returns true if we can undo. 622 | */ 623 | return this.historyPos > 0; 624 | }; 625 | 626 | Picker.prototype.canRedo = function() { 627 | /** 628 | * Returns true if we can redo. 629 | */ 630 | return this.historyPos < this.history.length - 1; 631 | }; 632 | 633 | Picker.prototype.undo = function() { 634 | /** 635 | * Reverts to the previous state in the history array. 636 | */ 637 | if (!this.canUndo()) { 638 | return; 639 | } 640 | this.state.restoreState(this.history[--this.historyPos]); 641 | this.saveState(); 642 | }; 643 | 644 | Picker.prototype.redo = function() { 645 | /** 646 | * Proceeds to the next state in the history array. 647 | */ 648 | if (!this.canRedo()) { 649 | return; 650 | } 651 | this.state.restoreState(this.history[++this.historyPos]); 652 | this.saveState(); 653 | }; 654 | 655 | Picker.prototype.resetToFavorites = function (favorites, useSettings) { 656 | /** 657 | * Creates a clean state with the items given in favorites (as 658 | * identifiers) as found favorites. 659 | * 660 | * If useSettings is given, then those will be the settings used and 661 | * any favorites that don't fit the parameters will be discarded. 662 | * Otherwise, the settings will be set by the settingsFromFavorites 663 | * option, or set to the default otherwise. 664 | */ 665 | var finalFavorites = []; 666 | var i; 667 | 668 | for (i = 0; i < favorites.length; i ++) { 669 | // Only add the item if it matches the settings (or if we don't have any given settings) 670 | if (!useSettings || this.state.shouldIncludeItem(favorites[i], useSettings)) { 671 | finalFavorites.push(favorites[i]); 672 | } 673 | } 674 | 675 | if (!useSettings) { 676 | // If we don't have any given settings, then set the settings according to the favorites instead 677 | if (this.options.settingsFromFavorites) { 678 | useSettings = copyObject(this.options.defaultSettings, this.options.settingsFromFavorites(this.mapItems(favorites))); 679 | } 680 | else { 681 | useSettings = copyObject(this.options.defaultSettings); 682 | } 683 | } 684 | 685 | // This should set the entire state properly. 686 | this.state.initialize(useSettings); 687 | this.state.setFavorites(finalFavorites); 688 | this.initialFavorites = finalFavorites; 689 | this.pushHistory(); 690 | }; 691 | 692 | /* STATE */ 693 | 694 | Picker.prototype.saveState = function() { 695 | /** 696 | * Saves the given state in localStorage, assuming it is available. 697 | */ 698 | if (this.options.saveState) { 699 | this.options.saveState.call(this, this.state.getState()); 700 | } 701 | else if (localStorage && JSON && this.options.localStorageKey) { 702 | localStorage.setItem(this.options.localStorageKey, JSON.stringify(this.state.getState())); 703 | } 704 | }; 705 | 706 | Picker.prototype.loadState = function() { 707 | /** 708 | * Returns the state stored in localStorage, if there is one. 709 | */ 710 | var state; 711 | if (this.options.loadState) { 712 | state = this.options.loadState.call(this); 713 | } 714 | else if (localStorage && JSON && this.options.localStorageKey) { 715 | try { 716 | state = JSON.parse(localStorage.getItem(this.options.localStorageKey)); 717 | } catch (e) { 718 | return null; 719 | } 720 | } 721 | return state; 722 | }; 723 | 724 | Picker.prototype.isUntouched = function() { 725 | /** 726 | * Returns true if the state has not been touched (either it's a 727 | * completely clean state or one that only has found favorites 728 | * matching the state's initial favorites). 729 | */ 730 | var i; 731 | var arrays = this.state.arrays; 732 | var initialFavorites = this.initialFavorites || []; 733 | 734 | // If something is in eliminated/survived, it's not untouched 735 | if (arrays.eliminated.length > 0 || arrays.survived.length > 0) { 736 | return false; 737 | } 738 | 739 | // If we've got nothing in eliminated/survived and nothing in favorites, it is untouched 740 | if (arrays.favorites.length === 0) { 741 | return true; 742 | } 743 | 744 | // We have found favorites, but nothing eliminated/survived: check if the favorites match the initial favorites, if any 745 | // If it's the wrong number of favorites, it's not untouched 746 | if (arrays.favorites.length !== initialFavorites.length) { 747 | return false; 748 | } 749 | for (i = 0; i < arrays.favorites.length; i++) { 750 | if (initialFavorites[i] !== arrays.favorites[i]) { 751 | // This favorite doesn't match, so it's not untouched 752 | return false; 753 | } 754 | } 755 | return true; 756 | }; 757 | 758 | Picker.prototype.hasItems = function() { 759 | /** 760 | * Returns true if the picker has any items (that aren't filtered 761 | * out). 762 | */ 763 | return this.state.items.length > 0; 764 | }; 765 | 766 | /* ACTIONS */ 767 | 768 | Picker.prototype.pick = function(picked) { 769 | this.state.pick(picked); 770 | this.pushHistory(); 771 | }; 772 | 773 | Picker.prototype.pass = function() { 774 | this.state.pass(); 775 | this.pushHistory(); 776 | }; 777 | 778 | Picker.prototype.reset = function() { 779 | this.state.reset(); 780 | this.pushHistory(); 781 | }; 782 | 783 | Picker.prototype.setSettings = function(settings) { 784 | this.state.setSettings(settings); 785 | this.pushHistory(); 786 | }; 787 | 788 | Picker.prototype.setFavorites = function(favorites) { 789 | this.state.setFavorites(favorites); 790 | this.pushHistory(); 791 | }; 792 | 793 | /* PICKER UTILITY FUNCTIONS */ 794 | 795 | Picker.prototype.forEachItem = function(func) { 796 | /** 797 | * Executes func for each identifier in the picker's item map. 798 | */ 799 | var identifier; 800 | var result; 801 | 802 | for (identifier in this.itemMap) { 803 | if (this.itemMap.hasOwnProperty(identifier)) { 804 | result = func(identifier); 805 | if (result) { 806 | return result; 807 | } 808 | } 809 | } 810 | }; 811 | 812 | Picker.prototype.mapItems = function(identifiers) { 813 | /** 814 | * Gets an array of full item objects corresponding to the given 815 | * identifiers. 816 | */ 817 | var self = this; 818 | return map(identifiers, function(identifier) { 819 | return self.itemMap[identifier]; 820 | }); 821 | }; 822 | 823 | /* GENERAL UTILITY FUNCTIONS */ 824 | 825 | function isState(state) { 826 | /** 827 | * Returns true if the given state object has all the expected 828 | * properties (and can thus safely be passed into restoreState). 829 | */ 830 | return ( 831 | state && 832 | typeof state === 'object' && 833 | Array.isArray(state.eliminated) && 834 | Array.isArray(state.survived) && 835 | Array.isArray(state.current) && 836 | Array.isArray(state.evaluating) && 837 | Array.isArray(state.favorites) && 838 | (!state.settings || typeof state.settings === 'object') 839 | ); 840 | }; 841 | 842 | function copyArray(array) { 843 | /** 844 | * Returns a deep copy of the given data array. 845 | */ 846 | var result = []; 847 | var i; 848 | for (i = 0; i < array.length; i++) { 849 | if (array[i] && typeof array[i] === 'object') { 850 | if (Array.isArray(array[i])) { 851 | result[i] = copyArray(array[i]); 852 | } 853 | else { 854 | result[i] = copyObject(array[i]); 855 | } 856 | } 857 | else { 858 | result[i] = array[i]; 859 | } 860 | } 861 | return result; 862 | } 863 | 864 | function copyObject() { 865 | /** 866 | * Returns a deep copy of the given object(s), with properties of later 867 | * objects overriding those of earlier objects. 868 | */ 869 | var result = {}; 870 | var a, key; 871 | 872 | for (a = 0; a < arguments.length; a++) { 873 | for (key in arguments[a]) { 874 | if (arguments[a].hasOwnProperty(key)) { 875 | if (arguments[a][key] && typeof arguments[a][key] === 'object') { 876 | if (Array.isArray(arguments[a][key])) { 877 | result[key] = copyArray(arguments[a][key]); 878 | } 879 | else { 880 | result[key] = copyObject(arguments[a][key]); 881 | } 882 | } 883 | else { 884 | result[key] = arguments[a][key]; 885 | } 886 | } 887 | } 888 | } 889 | return result; 890 | } 891 | 892 | function map(array, func) { 893 | /** 894 | * Returns an array containing the result of calling func on each item 895 | * in the input array. 896 | */ 897 | var result = []; 898 | var i; 899 | for (i = 0; i < array.length; i++) { 900 | result[i] = func(array[i]); 901 | } 902 | return result; 903 | } 904 | 905 | function shuffle(array) { 906 | /** 907 | * Shuffles the given array to be in a random order. 908 | */ 909 | var currentIndex = array.length, temporaryValue, randomIndex; 910 | 911 | while (0 !== currentIndex) { 912 | randomIndex = Math.floor(Math.random() * currentIndex); 913 | currentIndex -= 1; 914 | 915 | temporaryValue = array[currentIndex]; 916 | array[currentIndex] = array[randomIndex]; 917 | array[randomIndex] = temporaryValue; 918 | } 919 | 920 | return array; 921 | } 922 | 923 | function parseQueryString(qs) { 924 | /** 925 | * Parses a query string (a=b&c=d) into an object. 926 | */ 927 | var query = {}; 928 | var split = qs.split('&'); 929 | var valueSplit; 930 | var i; 931 | 932 | for (i = 0; i < split.length; i++) { 933 | valueSplit = split[i].split('='); 934 | query[decodeURIComponent(valueSplit[0])] = valueSplit[1] ? decodeURIComponent(valueSplit[1]) : true; 935 | } 936 | return query; 937 | } 938 | 939 | return { 940 | Picker: Picker, 941 | PickerState: PickerState, 942 | isState: isState 943 | }; 944 | })); 945 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Favorite picker 2 | 3 | Broadly, this is a tool that allows users to pick certain items from a predefined set over others, repeatedly, until a single favorite is found, and then continue to pick for second place and so on, constructing a list. It is a generalization of the [Favorite Pokémon Picker](https://www.dragonflycave.com/favorite.html). If you haven't seen that tool before, look there and play around with it to get a better idea of what this is about. 4 | 5 | 6 | ## Basic usage 7 | 8 | To make a very basic favorite picker, you don't have to do any programming at all. Save the `picker.html` file as well as the `picker.js` and `picker-ui.js` files in the same folder, then edit `picker.html` to specify the items you want to pick between. Find the following lines: 9 | 10 | ``` 11 | var items = [ 12 | // Define your items here 13 | ]; 14 | ``` 15 | 16 | Between the square brackets, insert a list of items in this format: 17 | 18 | ``` 19 | {id: 'phoenix', name: 'Phoenix Wright', image: 'phoenixwright.png'}, 20 | {id: 'edgeworth', name: 'Miles Edgeworth', image: 'edgeworth.png'}, 21 | {id: 'maya', name: 'Maya Fey', image: 'mayafey.png'} 22 | ``` 23 | 24 | Technically, only the `id` parameter is required; if the `image` is missing, the name will be shown instead, and if the `name` is missing, the ID will be used instead. You can have as many items as you want, but ensure there is a comma after each item except the last one. If specified, the `image` should be a path relative to the `picker.html` page, or else an absolute URL starting with http:// or https://. Make sure all the values are surrounded by either single (') or double (") quotes; if a value contains an apostrophe or quote, either escape it by writing it as `\'` or `\"` (e.g. `'Farfetch\'d'`) or use the other kind of quote, assuming the value doesn't contain that as well (e.g. `"Farfetch'd"`). 25 | 26 | Ensure the `id`s you give to your items are *unique*. The picker will exit early and print a warning to the browser console if any items have the same ID. 27 | 28 | That's it; if you now visit the page by opening `picker.html` in a browser of your choice, it should allow you to pick between the items you specified and help you generate a complete favorite list. By uploading the files to a web host, you can make it accessible to others. Easy, isn't it? 29 | 30 | 31 | ### Basic customization 32 | 33 | There are a few easy ways to enhance and customize the picker requiring no real programming experience. 34 | 35 | First of all, you may notice that by default, refreshing the page or exiting it will lose all your progress. If you'd like the picker to save your state as you go, that's built in and all you need to do is add the **localStorageKey** option. Find these lines in the `picker.html` file: 36 | 37 | ``` 38 | var myPicker = new picker.Picker({ 39 | items: items 40 | }); 41 | ``` 42 | 43 | and change them to say: 44 | 45 | ``` 46 | var myPicker = new picker.Picker({ 47 | items: items, 48 | localStorageKey: 'picker-state' 49 | }); 50 | ``` 51 | 52 | (Note the comma after `items: items`!) 53 | 54 | That's it! Refresh the page, make a few choices, and then refresh again. Your state will be restored the same way as it was (assuming you're using a reasonably modern browser). 55 | 56 | The localStorageKey can be any string, not just `picker-state`. If you have multiple pickers on the same website, you're going to want to pick a different key for each one so that they don't get mixed up. If the site is using localStorage for anything else, make sure you don't use a key that clashes with that (`picker-state` is probably unlikely to). 57 | 58 | You can also specify the default `minBatchSize` and `maxBatchSize` picker settings. These will control how many items may be shown to the user at a time - the picker will try to show one fifth of the items in the current round in each batch, clamped by these two settings. (Note that if there are only two items left in the round, though, the picker will only be able to show two regardless.) You can specify these settings like this: 59 | 60 | ``` 61 | var myPicker = new picker.Picker({ 62 | items: items, 63 | localStorageKey: 'picker-state', 64 | defaultSettings: { 65 | minBatchSize: 3, 66 | maxBatchSize: 6 67 | } 68 | }); 69 | ``` 70 | 71 | Obviously, change the numbers to the numbers you want. By default, if you don't specify them, `minBatchSize` is 2 and `maxBatchSize` is 20. Note that a `minBatchSize` less than 2 will be treated as 2, as it doesn't make sense to show fewer than two items at a time, and if the `maxBatchSize` is less than the `minBatchSize`, the `minBatchSize` will be used. 72 | 73 | Finally, if you don't want the user to be able to manually reorder their favorite list by dragging and dropping, you can remove the "Sortable favorites" section from the file, as well as this line: 74 | 75 | ``` 76 | 77 | ``` 78 | 79 | 80 | ### Intermediate customization 81 | 82 | Other ways to customize the picker are a little more involved and require writing some HTML and perhaps a little bit of JavaScript, although you don't need to do anything complicated. 83 | 84 | 85 | #### Layout 86 | 87 | You can easily modify the HTML and CSS of `picker.html` almost however you like. You'll want to keep the IDs of the important elements used by the picker - `pick`, `pass`, `undo`, `redo`, `evaluating` and `favorites` - and ensure that the latter two are empty since the picker will otherwise empty them out to place the respective lists there, but otherwise, the picker should be fine if you muck around with the HTML and CSS. 88 | 89 | 90 | #### User-settable minimum/maximum batch size 91 | 92 | All you have to do to let the user set the maximum and/or minimum batch size is: 93 | 94 | 1. If you haven't already, add the default value you want for the setting(s) to `defaultSettings` as explained above. 95 | 2. Add a form element to the page and give it an ID, such as ``. Put it anywhere you like, give it any label you please - you could even make it a `select` element instead if you prefer, although in that case you must give it the class `setting-number` and ensure the option values are numerical, e.g. ``. 96 | 3. Find the `elements` option for the `PickerUI` in the `picker.html` file: 97 | ``` 98 | elements: { 99 | pick: "#pick", 100 | pass: "#pass", 101 | undo: "#undo", 102 | redo: "#redo", 103 | evaluating: "#evaluating", 104 | favorites: "#favorites" 105 | } 106 | ``` 107 | Modify it to add a `settings` property, which is an object containing the desired setting(s) (`maxBatchSize` and/or `minBatchSize`) as a key and a jQuery selector (CSS-like) for your form element as its value: 108 | ``` 109 | elements: { 110 | pick: "#pick", 111 | pass: "#pass", 112 | undo: "#undo", 113 | redo: "#redo", 114 | evaluating: "#evaluating", 115 | favorites: "#favorites", 116 | settings: { 117 | maxBatchSize: '#max-batch-size' 118 | } 119 | } 120 | ``` 121 | 122 | And that's it - you're done. If you refresh the page, the `maxBatchSize` will be updated when you change the value of the form element, with the UI updating accordingly. 123 | 124 | 125 | #### Custom Settings 126 | 127 | You can also add your own custom settings to the picker pretty easily. All you need to do is: 128 | 129 | 1. Add the default values for the settings to the `defaultSettings`. 130 | 2. Add some form elements to the page and give them IDs so that you can identify them, as above. You can also use multiple `radio` or `checkbox` inputs for a single setting and either give them a shared class or use an attribute selector like `[name=setting-name]`; for radio buttons, the setting will get the value of the selected input, while for checkboxes, the setting's value will be an array containing the values of the checked inputs. If you'd like the values to be treated as numerical (and stored that way in the state), also give the form elements the `setting-number` class. 131 | 3. Add selectors for your setting form fields to the `elements` option for the `PickerUI`, as above. 132 | 4. Do something with your setting! 133 | 134 | Step four, doing something with your setting, is where you'll have to break out some JavaScript - the functionality of the `minBatchSize` and `maxBatchSize` settings is built in if you choose to use those settings, but if you want to add custom settings of your own, you'll have to tell the picker what to do with them. There are a few hooks designed to let you modify the picker's functionality according to settings; the most useful are `getItemImageUrl` and `shouldIncludeItem`. 135 | 136 | 137 | ##### Customize presentation with `getItemImageUrl` 138 | 139 | You can use settings to alter the presentation of the picker, such as by using different sets of images. This can easily be achieved by using the `getItemImageUrl` option for the `PickerUI`. For instance, this will let the user choose to use either the images from the `portraits` folder or the `fullbody` folder: 140 | 141 | ``` 142 |

    Images:

    143 | 144 | ... 145 | 146 | var myPicker = new picker.Picker({ 147 | items: items, 148 | localStorageKey: 'picker-state', 149 | defaultSettings: { 150 | maxBatchSize: 10, 151 | imageFolder: 'portraits' 152 | } 153 | }); 154 | 155 | var pickerUI = new PickerUI(myPicker, { 156 | elements: { 157 | pick: "#pick", 158 | pass: "#pass", 159 | undo: "#undo", 160 | redo: "#redo", 161 | evaluating: "#evaluating", 162 | favorites: "#favorites", 163 | settings: { 164 | maxBatchSize: '#max-batch-size', 165 | imageFolder: '#images' 166 | } 167 | }, 168 | getItemImageUrl: function(item, settings) { 169 | return settings.imageFolder + '/' + item.image; 170 | } 171 | }); 172 | 173 | ``` 174 | 175 | 176 | ##### Filtering 177 | 178 | This is one of the most obvious use cases for settings: filter the items down to a smaller subset according to the user's selections. 179 | 180 | In most cases, you can do this by setting the `shouldIncludeItem` function in the `Picker` options. This is a function that takes two arguments, `item` (one of the item objects you defined) and `settings`, and should return `true` (or another truthy value) if this item should be included given these settings. In order to make such settings work, you may want to add custom properties to your items - you can add any custom properties you like! For example: 181 | 182 | ``` 183 |

    Include roles: 184 | 185 | 186 | 187 | 188 | 189 |

    190 | 191 |

    192 | 193 | ... 194 | 195 | var items = [ 196 | {id: 'phoenix', name: 'Phoenix Wright', image: 'phoenixwright.png', role: 'lawyer', recurring: true}, 197 | {id: 'edgeworth', name: 'Miles Edgeworth', image: 'edgeworth.png', role: 'lawyer', recurring: true}, 198 | {id: 'maya', name: 'Maya Fey', image: 'mayafey.png', role: 'assistant', recurring: true}, 199 | {id: 'redd-white', name: 'Redd White', image: 'reddwhite.png', role: 'witness', recurring: false} 200 | ... 201 | ]; 202 | 203 | var myPicker = new picker.Picker({ 204 | items: items, 205 | localStorageKey: 'picker-state', 206 | defaultSettings: { 207 | roles: ['lawyer', 'assistant', 'defendant', 'witness', 'other'], 208 | recurringOnly: true 209 | }, 210 | shouldIncludeItem: function(item, settings) { 211 | // Include only if: 212 | // 1. the character's role is in the list of roles we've checked, and 213 | // 2. we haven't checked the "recurring only" box, or the character is a recurring character. 214 | return settings.roles.indexOf(item.role) !== -1 && (!settings.recurringOnly || item.recurring); 215 | } 216 | }); 217 | 218 | var pickerUI = new PickerUI(myPicker, { 219 | elements: { 220 | pick: "#pick", 221 | pass: "#pass", 222 | undo: "#undo", 223 | redo: "#redo", 224 | evaluating: "#evaluating", 225 | favorites: "#favorites", 226 | settings: { 227 | roles: '.roles', 228 | recurringOnly: '#recurring-only' 229 | } 230 | } 231 | }); 232 | ``` 233 | 234 | Sometimes, though, you may need more complex filtering than just independently determining inclusion for each item on its own. For these cases, you can use the `getFilteredItems` option, which is a function that takes the current settings and should simply return a list of item identifiers that should be included given these settings. Note that specifying this function will completely override `shouldIncludeItem`! For example: 235 | 236 | ``` 237 | var items = [ 238 | {id: 'phoenix', name: 'Phoenix Wright', image: 'phoenixwright.png', role: 'lawyer', recurring: true}, 239 | {id: 'young-phoenix', name: 'Young Phoenix', image: 'young-phoenix.png', role: 'defendant', recurring: false, base: 'phoenix'}, 240 | {id: 'edgeworth', name: 'Miles Edgeworth', image: 'edgeworth.png', role: 'lawyer', recurring: true}, 241 | {id: 'young-edgeworth', name: 'Young Edgeworth', image: 'young-edgeworth.png', role: 'lawyer', recurring: false, base: 'edgeworth'}, 242 | {id: 'maya', name: 'Maya Fey', image: 'mayafey.png', role: 'assistant', recurring: true}, 243 | {id: 'redd-white', name: 'Redd White', image: 'reddwhite.png', role: 'witness', recurring: false} 244 | ... 245 | ]; 246 | 247 | var myPicker = new picker.Picker({ 248 | items: items, 249 | localStorageKey: 'picker-state', 250 | defaultSettings: { 251 | roles: ['lawyer', 'assistant', 'defendant', 'witness', 'other'], 252 | recurringOnly: true, 253 | noDuplicates: true 254 | }, 255 | getFilteredItems: function(settings) { 256 | var used = {}; 257 | var filteredList = []; 258 | for (var i = 0; i < items.length; i++) { 259 | // Skip item if: 260 | // 1. the character's role is not in the list of roles we've checked, or 261 | // 2. we checked the "recurring only" box, and the character is not a recurring character. 262 | if (settings.roles.indexOf(items[i].role) === -1 || settings.recurringOnly && !items[i].recurring) continue; 263 | 264 | // If we want no duplicates, make sure we're not already including a different incarnation of the same character. 265 | if (settings.noDuplicates) { 266 | // Skip if we're already including a character with this base. 267 | if (items[i].base && used[items[i].base]) continue; 268 | 269 | // Record that we're including this character, so we don't introduce duplicates later. 270 | used[items[i].base || items[i].id] = true; 271 | } 272 | filteredList.push(items[i].id); 273 | } 274 | return filteredList; 275 | } 276 | }); 277 | ``` 278 | 279 | 280 | #### Shortcode Links 281 | 282 | The picker also includes built-in support for shareable permalinks to your favorite list that allow anyone to continue a given list from its link. Using this feature is somewhat more involved, but still not too complicated if you use the `picker-shortcodes.html` file as a base over `picker.html`. The steps to get it working (with `picker-shortcodes.html`) are: 283 | 284 | 1. Give each of your items a unique `shortcode` property. The shortcodes should be short case-sensitive strings of URL-safe characters (sticking with something like letters, numbers, hyphens and underscores, which gives you 64 characters to work with, is a good idea), and they should *all be the same length*. The Favorite Pokémon Picker, with well over a thousand items, gets by on two-character shortcodes - two characters from a 64-character alphabet give you 4096 possibilities - so you probably don't need more than that, but you could also make them slightly longer in order to make them more meaningful. 285 | 2. Set the `shortcodeLength` option on the `Picker` to the length of your shortcodes (so, for instance, `2` if you use two-character shortcodes). Optionally, you can also set the `favoritesQueryParam` option to decide the name of the query parameter used for shortcode links (default: `'favs'`). 286 | 3. *If you are using custom settings for filtering*, you may want to set the `settingsFromFavorites` option on the `Picker` to a function that takes a list of items and returns any settings that should be set (overriding the default settings) when restoring this set of items. The idea here is that if your default settings filter out some items, then if you visit a shortcode link with some items that would be filtered out by default and choose to continue that list, you probably want to change the settings to permit these items, rather than alter the given list to conform to the settings. For example, supposing you have the settings from the example above, you'd want something like this: 287 | 288 | ``` 289 | var items = [ 290 | {id: 'phoenix', name: 'Phoenix Wright', image: 'phoenixwright.png', role: 'lawyer', recurring: true, shortcode: 'aa'}, 291 | {id: 'edgeworth', name: 'Miles Edgeworth', image: 'edgeworth.png', role: 'lawyer', recurring: true, shortcode: 'ab'}, 292 | {id: 'maya', name: 'Maya Fey', image: 'mayafey.png', role: 'assistant', recurring: true, shortcode: 'ac'}, 293 | {id: 'redd-white', name: 'Redd White', image: 'reddwhite.png', role: 'witness', recurring: false, shortcode: 'ad'} 294 | ... 295 | ]; 296 | 297 | var myPicker = new picker.Picker({ 298 | items: items, 299 | localStorageKey: 'picker-state', 300 | shortcodeLength: 2, 301 | defaultSettings: { 302 | roles: ['lawyer', 'assistant', 'defendant', 'witness', 'other'], 303 | recurringOnly: true 304 | }, 305 | shouldIncludeItem: function(item, settings) { 306 | // Include only if: 307 | // 1. the character's role is in the list of roles we've checked, and 308 | // 2. we haven't checked the recurring box, *or* the character is recurring. 309 | return settings.roles.indexOf(item.role) !== -1 && (!settings.recurringOnly || item.recurring); 310 | }, 311 | settingsFromFavorites: function(favorites) { 312 | var hasNonRecurring = false; 313 | for (var i = 0; i < favorites.length; i++) { 314 | if (!favorites[i].recurring) { 315 | hasNonRecurring = true; 316 | } 317 | } 318 | return { 319 | recurringOnly: !hasNonRecurring 320 | }; 321 | } 322 | }); 323 | ``` 324 | 325 | And that's about it - `picker-shortcodes.html` will handle the rest. Once implemented, there will be an automatically updating link below the current favorite list, and visiting that link will pop up a modal window showing the shared list as well as buttons allowing the user to continue from that list or dismiss it. 326 | 327 | **Keep in mind that due to the permanent nature of links, shortcodes should never be changed.** Once you make a public version of your picker where certain items have certain shortcodes, please ensure that in the future you only *add* shortcodes, or perhaps remove some if truly appropriate. Altering the picker so that a shortcode that used to refer to one item now refers to another will **change what existing links point to**! If your picker will foreseeably gain more items in the future (for example, the Favorite Pokémon Picker is updated whenever new Pokémon are added in new installments of the video game series), ensure the way you assign shortcodes is future-proof and won't require you to devise a different scheme later. 328 | 329 | 330 | ## Advanced Customization 331 | 332 | If you're comfortable writing significant JavaScript, there are a myriad more ways to customize and work with the picker. 333 | 334 | The picker's code is split into two [UMD](https://github.com/umdjs/umd) modules, `picker.js` (which contains all the actual logic and functionality of the picker) and `picker-ui.js` (a flexible jQuery-based user interface for the picker). This split means you can easily do away with `picker-ui.js` altogether and build your own UI with no jQuery dependency. For example, if you'd like to make a [React](https://reactjs.org) interface for the picker, you can do that easily: 335 | 336 | ``` 337 | var picker = require('./picker.js'); 338 | 339 | class PickerContainer extends React.Component { 340 | state = { 341 | picker: new picker.Picker({ 342 | ... 343 | }) 344 | } 345 | 346 | render() { 347 | var self = this; 348 | var picker = this.state.picker; 349 | var wrapAction = func => function () { 350 | func.apply(picker, arguments); 351 | self.setState({ picker: picker }); 352 | }; 353 | return ( 354 | 364 | ); 365 | } 366 | } 367 | ``` 368 | 369 | Then all you have to do is build a `Picker` component rendering your desired UI based on the props passed to it by the `PickerContainer`. (Note that you will have to, for example, keep track of the currently selected items separately.) Refer to the API reference below for additional properties and methods you can pass down to your Picker component. 370 | 371 | 372 | ## API reference 373 | 374 | 375 | ### `picker.PickerState` (module `picker.js`) 376 | 377 | The PickerState object represents the state of the picker's data, handles the algorithmic logic behind how it changes on user actions, and ensures the validity of the state. While for standard pickers meant to be used in-browser you should use the Picker object, which adds a lot of features around the basic state logic, you can use the PickerState object directly if you plan to do something more customized - if you're running a picker on the server side, say - and only want the picker logic itself. 378 | 379 | 380 | #### `picker.PickerState(options)` 381 | 382 | Constructs and returns a PickerState. The options object specifies options for the PickerState: 383 | 384 | - `items` (array): **Required**. A plain list of all valid item identifiers (IDs). 385 | - `getBatchSize` (`function(currentSize, settings)`): A function that takes the current number of items in this round and the current settings and returns the number of items that should be displayed in each batch of items. By default this is the `currentSize` divided by five, clamped by the `minBatchSize` and `maxBatchSize` settings. 386 | - `shouldIncludeItem` (`function(identifier, settings)`): A function that takes an item identifier and the current settings and returns a truthy value if that item should be included given these settings. By default, all items are always included. 387 | - `getFilteredItems` (`function(settings)`): A function that takes the current settings and returns an array of item identifiers that should be included given these settings. By default, all items are included. (Specifying this will override any `shouldIncludeItem` function.) 388 | - `defaultSettings` (object): An object of default settings (see the settings part of the documentation). 389 | 390 | 391 | #### PickerState public methods 392 | 393 | - `getState()`: Returns a *dehydrated state* representing this PickerState. This is a plain JavaScript object with the following properties: 394 | - `eliminated` (array): A list of objects of the form `{id: 'identifier', eliminatedBy: ['a', 'b']}`, where the `id` is an item identifier and `eliminatedBy` is an array of item identifiers that were picked over this item. 395 | - `survived` (array): A list of item identifiers that have survived this round so far (i.e. were picked from their respective batches). 396 | - `current` (array): A list of item identifiers that have yet to be presented to the user this round. 397 | - `evaluating` (array): A list of item identifiers in the batch of items currently being evaluated. 398 | - `favorites` (array): A list of item identifiers that have been discovered as favorites, in order. 399 | - `settings` (object): The current picker settings. 400 | - `initialize(settings)`: Initializes the PickerState with the given settings. 401 | - `restoreState(state)`: Takes a *dehydrated state* and restores it into the current state. 402 | - `reset()`: Resets the PickerState, re-initializing it with the current settings. 403 | - `setSettings(settings)`: Sets new settings for the state, while ensuring the continued integrity of the state. 404 | - `setFavorites(favorites)`: Takes a list of item identifiers and overwrites the `favorites` list with them, while ensuring the continued integrity of the state. 405 | - `getBatchSize(currentSize)`: Takes the size of a round and returns the appropriate batch size. 406 | - `resetBatchSize()`: Sets the current batch size to whatever the current batch size should be given the size of the current round and adjusts the evaluating batch accordingly. 407 | - `pick(picked)`: Picks a list of items. `picked` should be an array of item identifiers from the current evaluating batch, which the user has picked over the rest of the items in the batch. There is technically nothing ensuring these items are actually from the evaluating batch, so you could do some funky things with it, but this is highly disrecommended unless you really know what you're doing algorithmically. 408 | - `pass()`: Passes on the current evaluating batch. This is equivalent to picking every item in the batch. 409 | 410 | 411 | ### `picker.Picker` (module `picker.js`) 412 | 413 | The Picker object wraps the PickerState and provides various additional functionality to enhance it: an undo history, automatically saving the current state after user actions and loading the current state on startup, and the shortcode link functionality described above. 414 | 415 | 416 | #### `picker.Picker(options)` 417 | 418 | Constructs and returns a Picker. The options object specifies options for the Picker: 419 | 420 | - `items` (array): **Required**. An array of item objects with at least an `id` property, and possibly `name` and `image` properties and any custom properties required for this particular Picker. 421 | - `localStorageKey`: A string specifying the key used for the favorite state in the browser's localStorage. Set this to a string such as 'picker-state' in order to automatically store the picker state in the user's browser under this key. (The default is to not store it, since otherwise the picker might inadvertently clobber a localStorage key already in use on the host website.) 422 | - `defaultSettings` (object): An object of default settings for the Picker. May include a `minBatchSize` and/or `maxBatchSize`, as well as any custom settings used for this Picker. 423 | - `historyLength` (integer): An integer specifying how many history entries should be maintained for undo/redo purposes. (Default: `3`) 424 | - `getBatchSize` (`function(currentSize, settings)`): A function that takes the current number of items in this round and the current settings and returns the number of items that should be displayed in each batch of items. By default this is the `currentSize` divided by five, clamped by the `minBatchSize` and `maxBatchSize` settings. 425 | - `shouldIncludeItem` (`function(item, settings)`): A function that takes an item object and the current settings and returns a truthy value if that item should be included given these settings. By default, all items are included. 426 | - `getFilteredItems` (`function(settings)`): A function that takes the current settings and returns an array of item identifiers that should be included given these settings. By default, all items are included. (Specifying this will override any `shouldIncludeItem` function.) 427 | - `modifyState` (`function(state)`): A function that takes a dehydrated state that we want to restore and returns a transformed version of the state. Useful for backwards-compatibility purposes - if the user has an outdated state saved in their browser, this hook allows you to alter the state or even replace it entirely before it's restored. By default, the unmodified state is restored, provided it's a valid state. 428 | - `onLoadState` (`function(missingItems, extraItems)`): A function called when a saved state has just been loaded, with the `Picker` object as `this`. This hook allows you to do any work that needs to be done after a state is restored. For example, the Favorite Pokémon Picker uses this hook to display a "Welcome back" message on the page explaining that the previous state has been loaded. `missingItems` and `extraItems` are arrays containing any item identifiers not present in the loaded state even though they ought to be there according to the options passed to the picker, and any item identifiers present in the loaded state even though they shouldn't be there, respectively. 429 | 430 | In practice, unless your users are manually messing with their state, these parameters will come into play only if you've edited the picker since the user last used it, such as by adding more items or removing some items. The picker will figure it out and silently edit the state to match with the current version of the picker, but these parameters allow you to do something with this information, such as informing the user that items have been added/removed. 431 | - `saveState` (`function(state)`): A function that takes a dehydrated state and saves it, used to override the default localStorage save feature. 432 | - `loadState` (`function()`): A function that retrieves and returns a dehydrated state that should be loaded for the user, used to override the default localStorage save feature. This is only run once, when first initializing the picker; if you need to do something like fetching saved state data asynchronously, then construct the Picker object *after* you've fetched the data: 433 | ``` 434 | fetchMySavedState(function (err, loadedState) { 435 | var myPicker = picker.Picker({ 436 | items: ..., 437 | saveState: function (state) { 438 | saveMyState(state); 439 | }, 440 | loadState: function() { 441 | return loadedState; 442 | } 443 | }); 444 | }); 445 | ``` 446 | - `favoritesQueryParam`: A string specifying a query parameter used to specify a shortcode string for a favorite list (see the shortcode section of the documentation). (Default: `'favs'`) 447 | - `shortcodeLength`: The number of characters used for each item's shortcode (see the shortcode section of the documentation). 448 | - `settingsFromFavorites` (`function(favorites)`): A function that takes a list of items and returns a settings object representing those settings that should be assumed when this favorite list is restored through a shortcode string (see the shortcode section of the documentation). 449 | 450 | 451 | #### Picker public methods 452 | 453 | - `getFavorites()`: Gets the list of items on the current found favorites list. 454 | - `getEvaluating()`: Gets the list of items currently being evaluated. 455 | - `getSettings()`: Gets the current settings of the picker. 456 | - `getSharedFavorites()`: Gets the favorite list shared by this shortcode link (see the shortcode section of the documentation). 457 | - `getShortcodeString()`: Gets the shortcode string for the current favorite list (see the shortcode section of the documentation). 458 | - `getShortcodeLink()`: Gets the full relative shortcode link, based on the `favoritesQueryParam` option and the `getShortcodeString()` method (see the shortcode section of the documentation). 459 | - `parseShortcodeString(shortcodeString)`: Takes a shortcode string and parses it into a list of item identifiers (see the shortcode section of the documentation). 460 | - `pushHistory()`: Pushes the current state into the undo history and saves it. 461 | - `canUndo()`: Returns `true` if you can undo (i.e. if there is a previous history entry), `false` otherwise. 462 | - `canRedo()`: Returns `true` if you can redo (i.e. if there is a next history entry), `false` otherwise. 463 | - `undo()`: Undoes the previous action, returning to the previous state. 464 | - `redo()`: Redoes a previously undone action, returning to that state. 465 | - `resetToFavorites(favorites, useSettings)`: Sets the state to a clean slate with just the given favorites listed in Found Favorites. `useSettings` can be a settings object, in which case that settings object is set and used to determine if a favorite given by the list should be kept or discarded, according to the `shouldIncludeItem` function. Otherwise, however, the settings will be set according to the `settingsFromFavorites` function. 466 | - `saveState()`: Saves the current state. By default, this is a noop unless the `localStorageKey` option is specified, in which case the state will be saved to localStorage under that key; however, if a `saveState` function is provided in the options, that will be run instead. 467 | - `loadState()`: Loads and returns a dehydrated state. By default, if the `localStorageKey` option is specified, it'll retrieve the state from there; otherwise, it returns `undefined`. However, if a `loadState` function is provided in the options, it will return the result of that instead. 468 | - `isUntouched()`: Returns `true` if the picker state is untouched - that is, if the user has not made any picks or passes. 469 | - `hasItems()`: Returns `true` if the *filtered* list of items - that is, the list of items for which the provided `shouldIncludeItem` function returns a truthy value - has at least one item in it. 470 | - `pick(picked)`: Takes a list of item identifiers and picks them from the current evaluating batch. (See the `pick` method of `PickerState`.) 471 | - `pass()`: Passes on this batch of items. This is equivalent to picking every item in the batch. 472 | - `reset()`: Resets the picker. 473 | - `setSettings(settings)`: Sets the picker settings and re-filters items accordingly. 474 | - `setFavorites(favorites)`: Takes a list of item identifiers and overwrites the `favorites` list with them, while ensuring the continued integrity of the state. 475 | 476 | 477 | ### `PickerUI` (module `picker-ui.js`) 478 | 479 | This separate module contains a full-featured UI for the picker, which depends on [jQuery](https://jquery.com). If you'd like to build your own UI using a different UI library or vanilla JavaScript, you can skip `picker-ui.js` altogether and just call methods on your `Picker` objects yourself from your own code. 480 | 481 | Otherwise, the provided PickerUI should be pretty easy to use, particularly with the default `picker.html` file (or `picker-shortcodes.html`) but also if you build your own custom layout. 482 | 483 | 484 | #### `PickerUI(picker, options)` 485 | 486 | Constructs and returns a PickerUI. The `picker` parameter should be a `Picker` object. The `options` parameter is an object specifying additional options for the picker UI: 487 | 488 | - `elements` (object): **Required.** An object mapping predefined element names to jQuery selectors for those elements. You don't need to touch this if you're using the provided `picker.html` or `picker-shortcodes.html`. The elements are: 489 | - `pick`: The "Pick" button. 490 | - `pass`: The "Pass" button. 491 | - `undo`: The "Undo" button. 492 | - `redo`: The "Redo" button. 493 | - `evaluating`: The container for the current evaluating batch (should be a `ul` element, unless you set the `wrapItem` function). 494 | - `favorites`: The container for the found favorites (should be a `ul` or `ol` element, unless you set the `wrapItem` function). 495 | - `reset`: **Optional**. Any extra static reset button(s) you may have placed in your HTML. 496 | - `shortcodeLink`: **Optional**. The shortcode link (only used if the `Picker` object has the `favoritesQueryParam` and `shortcodeLength` options set). 497 | - `sharedList`: **Optional**. The container for the shared list for this shortcode link (should be a `ul` or `ol` element, unless you set the `wrapItem` function) (see the shortcode part of the documentation). 498 | - `sharedListContainer`: **Optional**. The outer container (e.g. a modal window) for a shared list displayed when visiting a shortcode link (see the shortcode section of the documentation). 499 | - `sharedListContinue`: **Optional**. A button that continues from the shared list given by this shortcode link (see the shortcode section of the documentation). 500 | - `sharedListSkip`: **Optional**. Any buttons to dismiss a shared list given by a shortcode link (see the shortcode section of the documentation). 501 | - `settings`: A sub-object mapping setting names to jQuery selectors for setting elements (see the settings part of the documentation). 502 | - `messages` (object): Optionally, if you would like to override some or all of the messages displayed in the picker - such as to display them in a different language - you can specify them here. The messages are: 503 | - `reset`: The default text of generated reset buttons. By default, this is "Reset". 504 | - `mustSelect`: The alert shown to the user if they try to pick without having selected anything. By default, this is "You must select something first! If you're indifferent, press Pass." 505 | - `orderedAll`: The message displayed when the user has ordered every included item. By default, this is "You have ordered every available item!" 506 | - `noItems`: The message displayed if the user has selected settings such that every item is filtered out. By default, this is "There are no items that fit your criteria! Set some different options and try again." 507 | - `resetWarning`: This is a general message prompting the user for whether they would like to reset their state. By default, this is "Are you sure you wish to reset your state? All your found favorites and current progress will be lost." 508 | - `onUpdate` (`function()`): A function to be called whenever the UI has updated. `this` will be set to the `PickerUI` object, from which you can also access `this.picker` and `this.picker.state`. The Favorite Pokémon Picker uses this function to display the number of Pokémon remaining in this round, a dump of the current dehydrated state for debugging purposes, and more. 509 | - `getItemImageUrl` (`function(item, settings)`): By default, the picker will use the `image` property of the item as the image source if it's present. However, if you'd rather derive an image URL from other properties of the item and/or the current settings, you can specify this function. 510 | - `wrapItem` (`function(itemContent)`): A function that takes the contents representing a given item and wraps them in a container element, returning the container (as an element or jQuery object). By default, it wraps the item in a `li` element; if you need it to be something else, like a `div`, or want to do extra wrapping for styling purposes, overriding this is a lot simpler than overriding `getItemElem` altogether. 511 | - `getItemElem` (`function(item, settings)`): If `wrapItem` and `getItemImageUrl` aren't enough, you can write your own function taking an item object and the current settings and returning an element or jQuery object representing this item. 512 | 513 | 514 | #### PickerUI public methods 515 | 516 | - `initialize()`: Initializes the picker UI, setting any setting elements and performing the first UI update. 517 | - `getSetting(setting)`: Gets the current value of a setting element (see the settings part of the documentation). 518 | - `setSetting(setting, value)`: Sets the value of a setting element (see the settings part of the documentation). 519 | - `getSettings()`: Gets a settings object from all defined setting elements (see the settings part of the documentation). 520 | - `setSettings(settings)`: Takes a setting object and sets the specified setting elements accordingly. 521 | - `getSelected()`: Gets the list of items currently selected. 522 | - `update()`: Updates the UI according to the current picker state. 523 | - `pick(items)`: Picks the given item identifiers and updates the UI accordingly. 524 | - `pass()`: Passes on this batch and updates the UI accordingly. 525 | - `undo()`: Undoes the last action and updates the UI accordingly. 526 | - `redo()`: Redoes the previously undone action and updates the UI accordingly. 527 | - `reset()`: Prompts the user for a reset and, if they confirm, resets the picker and updates the UI accordingly. 528 | - `displaySharedList()`: Shows the shared list container. 529 | - `dismissSharedList()`: Hides the shared list container. 530 | - `getItemElem(item, settings)`: Returns a jQuery object for an element representing the given item with these settings. 531 | - `makeResetButton(text)`: Creates and returns a jQuery object for a reset button with the given text, which will prompt the user for a reset when pressed. Use if you'd like to conditionally add a reset button to the UI somewhere. 532 | 533 | 534 | The source code of `picker.js` and `picker-ui.js` contains various other methods less intended for public use - feel free to have a scroll through if the functionality you want isn't documented here. --------------------------------------------------------------------------------