From f22d154486fff88bbe0a595c32187b0109495090 Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Thu, 23 Jul 2015 11:47:46 -0700 Subject: [PATCH] feat(picker): new folder and label picker Summary: The new, styled folder and label picker! Some highlights: - Folder picker has custom icons and a divider - Label picker has checkboxes with off, checked, and intermediate state - Extracted LabelColorizer out of mail-label - Search will bold the results it found - Fixes to Tooltip to prevent it displaying at invalid moments - Keyboard UX improvements to Menu Test Plan: coming soon Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D1790 --- exports/nylas-component-kit.coffee | 5 +- .../category-picker/lib/category-picker.cjsx | 202 +++++++++++++----- .../stylesheets/category-picker.less | 68 ++++++ .../lib/thread-archive-button.cjsx | 1 + .../message-list/lib/thread-star-button.cjsx | 1 + .../message-list/lib/thread-tags-button.cjsx | 150 ------------- internal_packages/tooltip/lib/tooltip.cjsx | 7 + keymaps/base.cson | 9 +- src/components/mail-label.cjsx | 26 ++- src/components/menu.cjsx | 60 +++--- src/components/multiselect-action-bar.cjsx | 28 +-- src/components/popover.cjsx | 12 +- src/flux/models/utils.coffee | 11 + src/flux/stores/model-view-selection.coffee | 3 +- src/flux/tasks/change-labels-task.coffee | 8 +- src/sheet-toolbar.cjsx | 5 + src/sheet.cjsx | 5 + src/window-event-handler.coffee | 1 - static/components/menu.less | 27 ++- static/components/popover.less | 5 +- static/images/labels/tagging-checkbox@2x.png | Bin 0 -> 1218 bytes static/images/labels/tagging-checkmark@2x.png | Bin 0 -> 1550 bytes .../images/labels/tagging-conflicted@2x.png | Bin 0 -> 1188 bytes static/images/search/searchloupe@2x.png | Bin 0 -> 1029 bytes .../toolbar/ic-toolbar-movetofolder@2x.png | Bin 0 -> 14805 bytes static/images/toolbar/ic-toolbar-tag@2x.png | Bin 0 -> 15167 bytes .../toolbar/toolbar-star-selected@2x.png | Bin 1498 -> 1482 bytes static/images/toolbar/toolbar-star@2x.png | Bin 1709 -> 1709 bytes static/images/toolbar/toolbar-tags@2x.png | Bin 1103 -> 0 bytes 29 files changed, 356 insertions(+), 278 deletions(-) delete mode 100644 internal_packages/message-list/lib/thread-tags-button.cjsx create mode 100644 static/images/labels/tagging-checkbox@2x.png create mode 100644 static/images/labels/tagging-checkmark@2x.png create mode 100644 static/images/labels/tagging-conflicted@2x.png create mode 100644 static/images/search/searchloupe@2x.png create mode 100644 static/images/toolbar/ic-toolbar-movetofolder@2x.png create mode 100644 static/images/toolbar/ic-toolbar-tag@2x.png delete mode 100644 static/images/toolbar/toolbar-tags@2x.png diff --git a/exports/nylas-component-kit.coffee b/exports/nylas-component-kit.coffee index 4b36de1a9..32e66f68c 100644 --- a/exports/nylas-component-kit.coffee +++ b/exports/nylas-component-kit.coffee @@ -4,6 +4,8 @@ GeneratedForm, GeneratedFieldset} = require ('../src/components/generated-form') +{MailLabel, LabelColorizer} = require '../src/components/mail-label' + module.exports = # Models Menu: require '../src/components/menu' @@ -23,7 +25,8 @@ module.exports = InjectedComponent: require '../src/components/injected-component' TokenizingTextField: require '../src/components/tokenizing-text-field' TimeoutTransitionGroup: require '../src/components/timeout-transition-group' - MailLabel: require '../src/components/mail-label' + MailLabel: MailLabel + LabelColorizer: LabelColorizer FormItem: FormItem GeneratedForm: GeneratedForm GeneratedFieldset: GeneratedFieldset diff --git a/internal_packages/category-picker/lib/category-picker.cjsx b/internal_packages/category-picker/lib/category-picker.cjsx index 03086d07e..70c96cefc 100644 --- a/internal_packages/category-picker/lib/category-picker.cjsx +++ b/internal_packages/category-picker/lib/category-picker.cjsx @@ -1,43 +1,43 @@ _ = require 'underscore' React = require 'react' -{Actions, +{Utils, + Thread, + Actions, TaskQueue, CategoryStore, NamespaceStore, + WorkspaceStore, ChangeLabelsTask, ChangeFolderTask, - FocusedContentStore} = require 'nylas-exports' + FocusedContentStore, + FocusedCategoryStore} = require 'nylas-exports' {Menu, Popover, - RetinaImg} = require 'nylas-component-kit' + RetinaImg, + LabelColorizer} = require 'nylas-component-kit' # This changes the category on one or more threads. -# -# See internal_packages/thread-list/lib/thread-buttons.cjsx -# See internal_packages/message-list/lib/thread-tags-button.cjsx -# See internal_packages/message-list/lib/thread-archive-button.cjsx -# See internal_packages/message-list/lib/message-toolbar-items.cjsx - class CategoryPicker extends React.Component @displayName: "CategoryPicker" @containerRequired: false - @propTypes: - selection: React.PropTypes.object.isRequired - constructor: (@props) -> @state = _.extend @_recalculateState(@props), searchValue: "" + @contextTypes: + sheetDepth: React.PropTypes.number + componentDidMount: => @unsubscribers = [] @unsubscribers.push CategoryStore.listen @_onStoreChanged @unsubscribers.push NamespaceStore.listen @_onStoreChanged @unsubscribers.push FocusedContentStore.listen @_onStoreChanged + @unsubscribers.push FocusedCategoryStore.listen @_onStoreChanged @_commandUnsubscriber = atom.commands.add 'body', - "application:change-category": @_onChangeCategory + "application:change-category": @_onOpenCategoryPopover # If the threads we're picking categories for change, (like when they # get their categories updated), we expect our parents to pass us new @@ -52,9 +52,25 @@ class CategoryPicker extends React.Component @_commandUnsubscriber.dispose() render: => - button = headerComponents = [ @@ -62,39 +78,99 @@ class CategoryPicker extends React.Component tabIndex="1" key="textfield" className="search" + placeholder={placeholder} value={@state.searchValue} onChange={@_onSearchValueChange}/> ] - categoryDatum.id } - itemContent={@_itemContent} - itemChecked={ (categoryDatum) -> categoryDatum.usage > 0 } + itemContent={@_renderItemContent} onSelect={@_onSelectCategory} + defaultSelectedIndex={if @state.searchValue is "" then -1 else 0} /> - _tooltipLabel: -> - return "" unless @_namespace - if @_namespace.usesLabels() - return "Apply Labels" - else if @_namespace.usesFolders() - return "Move to Folder" - - _onChangeCategory: => + _onOpenCategoryPopover: => return unless @_threads().length > 0 + return unless @context.sheetDepth is WorkspaceStore.sheetStack().length - 1 @refs.popover.open() + return - _itemContent: (categoryDatum) => - {categoryDatum.display_name} + _renderItemContent: (categoryDatum) => + if categoryDatum.divider + return + if @_namespace?.usesLabels() + icon = @_renderCheckbox(categoryDatum) + else if @_namespace?.usesFolders() + icon = @_renderFolderIcon(categoryDatum) + else return + +
+ {icon} +
+ {@_renderBoldedSearchResults(categoryDatum)} +
+
+ + _renderCheckbox: (categoryDatum) -> + styles = {} + styles.backgroundColor = categoryDatum.backgroundColor + + if categoryDatum.usage is 0 + checkStatus = + else if categoryDatum.usage < categoryDatum.numThreads + checkStatus = @_onSelectCategory(categoryDatum)}/> + else + checkStatus = @_onSelectCategory(categoryDatum)}/> + +
+ @_onSelectCategory(categoryDatum)}/> + {checkStatus} +
+ + _renderFolderIcon: (categoryDatum) -> + + + _renderBoldedSearchResults: (categoryDatum) -> + name = categoryDatum.display_name + searchTerm = (@state.searchValue ? "").trim() + + return name if searchTerm.length is 0 + + re = Utils.wordSearchRegExp(searchTerm) + parts = name.split(re).map (part) -> + # The wordSearchRegExp looks for a leading non-word character to + # deterine if it's a valid place to search. As such, we need to not + # include that leading character as part of our match. + if re.test(part) + if /\W/.test(part[0]) + return {part[0]}{part[1..-1]} + else + return {part} + else return part + return {parts} _onSelectCategory: (categoryDatum) => return unless @_threads().length > 0 @@ -116,11 +192,12 @@ class CategoryPicker extends React.Component threadIds: @_threadIds() if @props.thread Actions.moveThread(@props.thread, task) - else if @props.selection + else if @props.items Actions.moveThreads(@_threads(), task) else throw new Error("Invalid organizationUnit") + @refs.popover.close() TaskQueue.enqueue(task) _onStoreChanged: => @@ -131,20 +208,33 @@ class CategoryPicker extends React.Component _onPopoverOpened: => @setState @_recalculateState(@props, searchValue: "") + @setState isPopoverOpen: true + + _onPopoverClosed: => + @setState isPopoverOpen: false _recalculateState: (props=@props, {searchValue}={}) => searchValue = searchValue ? @state?.searchValue ? "" - if @_threads(props).length is 0 + numThreads = @_threads(props).length + if numThreads is 0 return {categoryData: [], searchValue} @_namespace = NamespaceStore.current() return unless @_namespace - categories = CategoryStore.getCategories() + categories = [].concat(CategoryStore.getStandardCategories()) + .concat([{divider: true}]) + .concat(CategoryStore.getUserCategories()) + usageCount = @_categoryUsageCount(props, categories) + + allInInbox = @_allInInbox(usageCount, numThreads) + + displayData = {usageCount, numThreads} + categoryData = _.chain(categories) - .filter(@_isUserFacing) + .filter(_.partial(@_isUserFacing, allInInbox)) .filter(_.partial(@_isInSearch, searchValue)) - .map(_.partial(@_extendCategoryWithUsage, usageCount)) + .map(_.partial(@_extendCategoryWithDisplayData, displayData)) .value() return {categoryData, searchValue} @@ -157,34 +247,32 @@ class CategoryPicker extends React.Component return categoryUsageCount _isInSearch: (searchValue, category) -> - searchTerm = searchValue.trim().toLowerCase() - return true if searchTerm.length is 0 + return Utils.wordSearchRegExp(searchValue).test(category.displayName) - catName = category.displayName.trim().toLowerCase() + _isUserFacing: (allInInbox, category) => + hiddenCategories = [] + currentCategoryId = FocusedCategoryStore.categoryId() + if @_namespace?.usesLabels() + hiddenCategories = ["all", "spam", "trash", "drafts", "sent"] + if allInInbox + hiddenCategories.push("inbox") + return false if category.divider + else if @_namespace?.usesFolders() + hiddenCategories = ["drafts", "sent"] - wordIndices = [] - # Where a non-word character is followed by a word character - # We don't use \b (word boundary) because we want to split on slashes - # and dashes and other non word things - re = /\W[\w\d]/g - while match = re.exec(catName) then wordIndices.push(match.index) - # To shift to the start of each word. - wordIndices = wordIndices.map (i) -> i += 1 + return (category.name not in hiddenCategories) and (category.id isnt currentCategoryId) - # Always include the start - wordIndices.push(0) + _allInInbox: (usageCount, numThreads) -> + inbox = CategoryStore.getStandardCategory("inbox") + return usageCount[inbox.id] is numThreads - return catName.indexOf(searchTerm) in wordIndices - - _isUserFacing: (category) => - hiddenCategories = ["inbox", "all", "archive", "drafts", "sent"] - return category.name not in hiddenCategories - - _extendCategoryWithUsage: (usageCount, category, i, categories) -> + _extendCategoryWithDisplayData: ({usageCount, numThreads}, category) -> + return category if category.divider cat = category.toJSON() usage = usageCount[cat.id] ? 0 + cat.backgroundColor = LabelColorizer.backgroundColorDark(category) cat.usage = usage - cat.totalUsage = categories.length + cat.numThreads = numThreads return cat _threadCategories: (thread) => @@ -195,7 +283,7 @@ class CategoryPicker extends React.Component else throw new Error("Invalid organizationUnit") _threads: (props=@props) => - if props.selection then return (props.selection.items() ? []) + if props.items then return (props.items ? []) else if props.thread then return [props.thread] else return [] diff --git a/internal_packages/category-picker/stylesheets/category-picker.less b/internal_packages/category-picker/stylesheets/category-picker.less index 1d0653412..dc41c6662 100644 --- a/internal_packages/category-picker/stylesheets/category-picker.less +++ b/internal_packages/category-picker/stylesheets/category-picker.less @@ -1,4 +1,72 @@ @import "ui-variables"; .category-picker { + .popover { + } + + .menu { + + background: @background-secondary; + + .header-container { + border-bottom: 0; + } + + .item.selected:hover { + background-color: @accent-primary; + color: @text-color-inverse; + } + + .item:hover { + background-color: transparent; + color: inherit; + .primary, .secondary { + color: inherit; + } + } + + .item.divider { + background-color: #e0e0e0; + margin: 4px 0; + height: 2px; + padding: 0; + } + + } + + .btn.btn-toolbar { + margin-left: 0; + margin-right: 0; + } + + .check-wrap { + width: 14px; + height: 14px; + border-radius: 2px; + display: inline-block; + position: relative; + top: 2px; + } + + img.content-mask { + background-color: @text-color-subtle; + position: relative; + top: -1px; + } + .item.selected img.content-mask { + background-color: @text-color-inverse; + } + + img.check-img { + position: absolute; + } + + .category-item { + font-size: 14px; + } + + .category-display-name { + display: inline-block; + margin-left: 10px; + } } diff --git a/internal_packages/message-list/lib/thread-archive-button.cjsx b/internal_packages/message-list/lib/thread-archive-button.cjsx index 387797ed2..86f2d1fa1 100644 --- a/internal_packages/message-list/lib/thread-archive-button.cjsx +++ b/internal_packages/message-list/lib/thread-archive-button.cjsx @@ -5,6 +5,7 @@ React = require 'react' class ThreadArchiveButton extends React.Component @displayName: "ThreadArchiveButton" + @containerRequired: false render: => - - headerComponents = [ - - ] - - - item.id } - itemContent={@_itemContent} - itemChecked={@_itemChecked} - onSelect={@_onToggleTag} - /> - - - _itemContent: (tag) => - if tag.id is 'divider' - - else - tag.name.charAt(0).toUpperCase() + tag.name.slice(1) - - _itemChecked: (tag) => - return false unless @state.thread - @state.thread.hasCategoryId(tag.id) - - _onShowTags: => - # Always reset search state when the popover is shown - if @state.searchValue.length > 0 - @setState @_getStateForSearch('') - - _onToggleTag: (tag) => - return unless @state.thread - - if @state.thread.hasCategoryId(tag.id) - task = new AddRemoveTagsTask(@state.thread, [], [tag.id]) - else - task = new AddRemoveTagsTask(@state.thread, [tag.id], []) - - @refs.menu.setSelectedItem(null) - - TaskQueue.enqueue(task) - - _onFocusChange: (change) => - if change.impactsCollection('thread') - @_onStoreChange() - - _onStoreChange: => - @setState @_getStateForSearch(@state.searchValue) - - _onSearchValueChange: (event) => - @setState @_getStateForSearch(event.target.value) - - _getStateForSearch: (searchValue = '') => - searchTerm = searchValue.toLowerCase() - thread = FocusedContentStore.focused('thread') - return [] unless thread - - tags = _.filter TagsStore.items(), (tag) -> tag.name.toLowerCase().indexOf(searchTerm) is 0 - - # Some tags are "magic" state and can't be added/removed - tags = _.filter tags, (tag) -> not (tag.id in ['unseen', 'attachment', 'sending', 'drafts', 'sent']) - - # Some tags are readonly - tags = _.filter tags, (tag) -> not tag.readonly - - # Organize tags into currently applied / not applied - active = [] - inactive = [] - for tag in tags - if thread.hasCategoryId(tag.id) - active.push(tag) - else - inactive.push(tag) - - {searchValue, thread, tags: [].concat(active, inactive)} - -module.exports = ThreadTagsButton diff --git a/internal_packages/tooltip/lib/tooltip.cjsx b/internal_packages/tooltip/lib/tooltip.cjsx index f65bc4199..d83134ae6 100644 --- a/internal_packages/tooltip/lib/tooltip.cjsx +++ b/internal_packages/tooltip/lib/tooltip.cjsx @@ -35,6 +35,7 @@ class Tooltip extends React.Component componentWillUnmount: => clearTimeout @_showTimeout clearTimeout @_showDelayTimeout + @_mutationObserver?.disconnect() render: =>
@@ -72,6 +73,11 @@ class Tooltip extends React.Component return target _onTooltipEnter: (target) => + # https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver + @_mutationObserver?.disconnect() + @_mutationObserver = new MutationObserver @_onTooltipLeave + @_mutationObserver.observe(target.parentNode, attributes: true, subtree: true, childList: true) + @_lastTarget = target @_enteredTooltip = true clearTimeout(@_showTimeout) @@ -82,6 +88,7 @@ class Tooltip extends React.Component _onTooltipLeave: => return unless @_enteredTooltip + @_mutationObserver?.disconnect() @_enteredTooltip = false clearTimeout(@_showTimeout) @_hideTooltip() diff --git a/keymaps/base.cson b/keymaps/base.cson index 50d3c1532..60e0cf0c4 100644 --- a/keymaps/base.cson +++ b/keymaps/base.cson @@ -206,13 +206,12 @@ 'tab': 'native!' 'shift-tab': 'native!' - - # For menus 'body .menu, body .menu, body .menu input': - 'up': 'menu:move-up' - 'down': 'menu:move-down' - 'enter': 'menu:enter' + # and by "native!" I actually mean for it to just let React deal with + # it. + 'tab': 'native!' + 'shift-tab': 'native!' # For Popover Component 'body .popover-container, body .popover-container input': diff --git a/src/components/mail-label.cjsx b/src/components/mail-label.cjsx index 0158096c3..fc970d34f 100644 --- a/src/components/mail-label.cjsx +++ b/src/components/mail-label.cjsx @@ -2,19 +2,25 @@ React = require 'react' RetinaImg = require './retina-img' CategoryStore = require '../flux/stores/category-store' +LabelColorizer = + + color: (label) -> "hsl(#{label.hue()}, 50%, 34%)" + + backgroundColor: (label) -> "hsl(#{label.hue()}, 62%, 87%)" + backgroundColorDark: (label) -> "hsl(#{label.hue()}, 62%, 57%)" + + styles: (label) -> + color: LabelColorizer.color(label) + backgroundColor: LabelColorizer.backgroundColor(label) + boxShadow: "inset 0 0 1px hsl(#{label.hue()}, 62%, 47%), inset 0 1px 1px rgba(255,255,255,0.5), 0 0.5px 0 rgba(255,255,255,0.5)" + backgroundImage: 'linear-gradient(rgba(255,255,255, 0.4), rgba(255,255,255,0))' + class MailLabel extends React.Component @propTypes: label: React.PropTypes.object.isRequired onRemove: React.PropTypes.function render: -> - hue = @props.label.hue() - style = - backgroundColor: "hsl(#{hue}, 62%, 87%)" - color: "hsl(#{hue}, 50%, 34%)" - boxShadow: "inset 0 0 1px hsl(#{hue}, 62%, 47%), inset 0 1px 1px rgba(255,255,255,0.5), 0 0.5px 0 rgba(255,255,255,0.5)" - backgroundImage: 'linear-gradient(rgba(255,255,255, 0.4), rgba(255,255,255,0))' - classname = 'mail-label' content = @props.label.displayName @@ -25,14 +31,14 @@ class MailLabel extends React.Component x = -
{content}{x}
+
{content}{x}
_removable: -> isLockedLabel = @props.label.name in CategoryStore.LockedCategoryNames return @props.onRemove and not isLockedLabel -module.exports = MailLabel +module.exports = {MailLabel, LabelColorizer} diff --git a/src/components/menu.cjsx b/src/components/menu.cjsx index b51813f30..a2a01230d 100644 --- a/src/components/menu.cjsx +++ b/src/components/menu.cjsx @@ -126,6 +126,10 @@ class Menu extends React.Component - `onSelect` A {Function} called with the selected item when the user clicks an item in the menu or confirms their selection with the Enter key. + - `defaultSelectedIndex` The index of the item first selected if there + was no other previous index. Defaults to 0. Set to -1 if you want + nothing selected. + ### @propTypes: className: React.PropTypes.string, @@ -139,9 +143,11 @@ class Menu extends React.Component onSelect: React.PropTypes.func.isRequired, + defaultSelectedIndex: React.PropTypes.number + constructor: (@props) -> @state = - selectedIndex: 0 + selectedIndex: @props.defaultSelectedIndex ? 0 # Public: Returns the currently selected item. # @@ -152,16 +158,7 @@ class Menu extends React.Component # null to remove the selection # setSelectedItem: (item) => - @setState - selectedIndex: @props.items.indexOf(item) - - componentDidMount: => - @subscriptions = new CompositeDisposable() - @subscriptions.add atom.commands.add '.menu', { - 'menu:move-up': => @_onShiftSelectedIndex(-1) - 'menu:move-down': => @_onShiftSelectedIndex(1) - 'menu:enter': => @_onEnter() - } + @setState selectedIndex: @props.items.indexOf(item) componentWillReceiveProps: (newProps) => # Attempt to preserve selection across props.items changes by @@ -171,7 +168,7 @@ class Menu extends React.Component selection = @props.items[@state.selectedIndex] newSelectionIndex = 0 else - newSelectionIndex = -1 + newSelectionIndex = newProps.defaultSelectedIndex ? -1 if selection? selectionKey = @props.itemKey(selection) @@ -188,15 +185,14 @@ class Menu extends React.Component if adjustment isnt 0 container.scrollTop += adjustment - componentWillUnmount: => - @subscriptions?.dispose() - render: => hc = @props.headerComponents ? [] if hc.length is 0 then hc = fc = @props.footerComponents ? [] if fc.length is 0 then fc = -
+
{hc}
@@ -206,6 +202,18 @@ class Menu extends React.Component
+ _onKeyDown: (event) => + if event.key is "Enter" + @_onEnter() + else if event.key is "ArrowUp" or (event.key is "Tab" and event.shiftKey) + @_onShiftSelectedIndex(-1) + event.preventDefault() + else if event.key is "ArrowDown" or event.key is "Tab" + @_onShiftSelectedIndex(1) + event.preventDefault() + + return + _contentContainer: => items = @props.items.map(@_itemComponentForItem) ? [] contentClass = classNames @@ -234,18 +242,22 @@ class Menu extends React.Component _onShiftSelectedIndex: (delta) => return if @props.items.length is 0 + index = @state.selectedIndex + delta + + isDivider = true + while isDivider + item = @props.items[index] + break unless item + if @props.itemContent(item).props?.divider + if delta > 0 then index += 1 + else if delta < 0 then index -= 1 + else isDivider = false + index = Math.max(0, Math.min(@props.items.length-1, index)) # Update the selected index - @setState - selectedIndex: index - - # Fire the shift method again to move selection past the divider - # if the new selected item is a divider. - itemContent = @props.itemContent(@props.items[index]) - isDivider = itemContent.props?.divider - @_onShiftSelectedIndex(delta) if isDivider + @setState selectedIndex: index _onEnter: => item = @props.items[@state.selectedIndex] diff --git a/src/components/multiselect-action-bar.cjsx b/src/components/multiselect-action-bar.cjsx index fe6a5b387..c6c263b4d 100644 --- a/src/components/multiselect-action-bar.cjsx +++ b/src/components/multiselect-action-bar.cjsx @@ -2,7 +2,8 @@ React = require "react/addons" classNames = require 'classnames' _ = require 'underscore' -{Actions, +{Utils, + Actions, WorkspaceStore} = require "nylas-exports" InjectedComponentSet = require './injected-component-set' RetinaImg = require './retina-img' @@ -75,10 +76,8 @@ class MultiselectActionBar extends React.Component @unsubscribers.push WorkspaceStore.listen @_onChange shouldComponentUpdate: (nextProps, nextState) => - @props.collection isnt nextProps.collection or - @state.count isnt nextState.count or - @state.view isnt nextState.view or - @state.type isnt nextState.type + not Utils.isEqualReact(nextProps, @props) or + not Utils.isEqualReact(nextState, @state) render: =>
@@ -98,27 +97,30 @@ class MultiselectActionBar extends React.Component _renderActions: => return
unless @state.view + exposedProps={selection: @state.view.selection, items: @state.items} /> _label: => - if @state.count > 1 - "#{@state.count} #{@props.collection}s selected" - else if @state.count is 1 - "#{@state.count} #{@props.collection} selected" + if @state.items.length > 1 + "#{@state.items.length} #{@props.collection}s selected" + else if @state.items.length is 1 + "#{@state.items.length} #{@props.collection} selected" else "" _classSet: => classNames "selection-bar": true - "enabled": @state.count > 0 + "enabled": @state.items.length > 0 _getStateFromStores: (props) => props ?= @props view = props.dataStore.view() + items = view?.selection.items() ? [] - view: view - count: view?.selection.items().length + return { + view: view + items: items + } _onChange: => @setState(@_getStateFromStores()) diff --git a/src/components/popover.cjsx b/src/components/popover.cjsx index f8fa595e8..3bf98938b 100644 --- a/src/components/popover.cjsx +++ b/src/components/popover.cjsx @@ -87,12 +87,12 @@ class Popover extends React.Component @_focusOnOpen = true @setState showing: true - if @props.onOpened? - @props.onOpened() + @props.onOpened?() close: => @setState showing: false + @props.onClosed?() _focusImportantElement: => # Automatically focus the element inside us with the lowest tab index @@ -143,6 +143,14 @@ class Popover extends React.Component 'top': -10 'left':-12 + if @props.direction is "down-align-left" + popoverStyle = _.extend popoverStyle, + 'transform': 'translate(0, 2px)' + 'top': '100%' + 'left': 0 + pointerStyle = _.extend pointerStyle, + 'display': 'none' + popoverComponent =
{@props.children}
diff --git a/src/flux/models/utils.coffee b/src/flux/models/utils.coffee index 921cb1102..b7a1c5e8a 100644 --- a/src/flux/models/utils.coffee +++ b/src/flux/models/utils.coffee @@ -11,6 +11,17 @@ Utils = isHash: (object) -> _.isObject(object) and not _.isFunction(object) and not _.isArray(object) + escapeRegExp: (str) -> + str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&") + + # Generates a new RegExp that is great for basic search fields. It + # checks if the test string is at the start of words + # + # See regex explanation and test here: + # https://regex101.com/r/zG7aW4/2 + wordSearchRegExp: (str="") -> + new RegExp("((?:^|\\W|$)#{Utils.escapeRegExp(str.trim())})", "ig") + # Takes an optional customizer. The customizer is passed the key and the # new cloned value for that key. The customizer is expected to either # modify the value and return it or simply be the identity function. diff --git a/src/flux/stores/model-view-selection.coffee b/src/flux/stores/model-view-selection.coffee index 4635b997e..de5fd16f3 100644 --- a/src/flux/stores/model-view-selection.coffee +++ b/src/flux/stores/model-view-selection.coffee @@ -14,8 +14,7 @@ class ModelViewSelection ids: -> _.pluck(@_items, 'id') - items: -> - @_items + items: -> _.clone(@_items) top: -> @_items[@_items.length - 1] diff --git a/src/flux/tasks/change-labels-task.coffee b/src/flux/tasks/change-labels-task.coffee index 13ae143f8..0471a93ab 100644 --- a/src/flux/tasks/change-labels-task.coffee +++ b/src/flux/tasks/change-labels-task.coffee @@ -18,7 +18,7 @@ class ChangeLabelsTask extends ChangeCategoryTask constructor: ({@labelsToAdd, @labelsToRemove, @threadIds, @messageIds}={}) -> @threadIds ?= []; @messageIds ?= [] @objectIds = @threadIds.concat(@messageIds) - @_newLabels = [] + @_newLabels = {} super label: -> "Applying labels…" @@ -46,7 +46,7 @@ class ChangeLabelsTask extends ChangeCategoryTask return Promise.resolve(Task.Status.Finished) requestBody: (id) -> - labels: @_newLabels.map (l) -> l.id + labels: @_newLabels[id].map (l) -> l.id createUndoTask: -> labelsToAdd = @labelsToRemove @@ -59,7 +59,7 @@ class ChangeLabelsTask extends ChangeCategoryTask # Called from super-class's `performLocal` localUpdateThread: (thread, categories) -> newLabels = @_newLabelSet(thread, categories) - @_newLabels = newLabels + @_newLabels[thread.id] = newLabels messageQuery = DatabaseStore.findAll(Message, threadId: thread.id) childSavePromise = messageQuery.then (messages) -> @@ -79,7 +79,7 @@ class ChangeLabelsTask extends ChangeCategoryTask # Called from super-class's `performLocal` localUpdateMessage: (message, categories) -> message.labels = @_newLabelSet(message, categories) - @_newLabels = message.labels + @_newLabels[message.id] = message.labels return DatabaseStore.persistModel(message) # Returns a new set of {Label} objects that incoprates the existing, diff --git a/src/sheet-toolbar.cjsx b/src/sheet-toolbar.cjsx index 67c9a8dfd..925bf87a2 100644 --- a/src/sheet-toolbar.cjsx +++ b/src/sheet-toolbar.cjsx @@ -84,6 +84,11 @@ class Toolbar extends React.Component data: React.PropTypes.object depth: React.PropTypes.number + @childContextTypes: + sheetDepth: React.PropTypes.number + getChildContext: => + sheetDepth: @props.depth + constructor: (@props) -> @state = @_getStateFromStores() diff --git a/src/sheet.cjsx b/src/sheet.cjsx index 5e2f4e1f0..c82457fa8 100644 --- a/src/sheet.cjsx +++ b/src/sheet.cjsx @@ -16,6 +16,11 @@ class Sheet extends React.Component depth: React.PropTypes.number.isRequired onColumnSizeChanged: React.PropTypes.func + @childContextTypes: + sheetDepth: React.PropTypes.number + getChildContext: => + sheetDepth: @props.depth + constructor: (@props) -> @state = @_getStateFromStores() diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee index 86e44ebd4..f06eb4c78 100644 --- a/src/window-event-handler.coffee +++ b/src/window-event-handler.coffee @@ -121,7 +121,6 @@ class WindowEventHandler onKeydown: (event) -> atom.keymaps.handleKeyboardEvent(event) - event.stopImmediatePropagation() # Important: even though we don't do anything here, we need to catch the # drop event to prevent the browser from navigating the to the "url" of the diff --git a/static/components/menu.less b/static/components/menu.less index 755fdbb45..ceff4a59a 100644 --- a/static/components/menu.less +++ b/static/components/menu.less @@ -1,28 +1,39 @@ @import "ui-variables"; .menu { + flex: 1; + display: flex; + flex-direction: column; + .header-container { + flex-shrink: 0; // Don't squish the header! There may be a search box here background-color: @background-color-secondary; - border-bottom:1px solid @base-border-color; - padding:8px; + border-bottom: 1px solid @base-border-color; + padding: 3px; position: relative; input.search { - border:1px solid darken(@background-color-secondary, 10%); + border: 1px solid darken(@background-color-secondary, 10%); + border-radius: 3px; + padding-left: 0; background-color: white; - border-radius:12px; - padding-left: 10px; + background-repeat: no-repeat; + background-image: url("../static/images/search/searchloupe@2x.png"); + background-size: 15px 15px; + background-position: 7px 4px; + text-indent: 31px; + box-shadow: inset 0 1px 0 rgba(0,0,0,0.05), 0 1px 0 rgba(0,0,0,0.05) } } .footer-container { position: relative; - border-top:1px solid @base-border-color; } .content-container { background: @background-color-secondary; width: 100%; margin-top: -1px; + overflow: auto; } .item { @@ -30,7 +41,7 @@ padding-left: @padding-base-horizontal; padding-top: @padding-base-vertical; padding-bottom: @padding-base-vertical; - cursor: pointer; + cursor: default; color: @text-color; width: 100%; overflow: hidden; @@ -74,7 +85,7 @@ background-image:url(./images/menu/checked-selected@2x.png); } - .item.selected, .item:hover { + .item.selected, .item:hover, .item:active { text-decoration: none; background-color: @accent-primary; color: @text-color-inverse; diff --git a/static/components/popover.less b/static/components/popover.less index 8e777d2db..5eb0a2a78 100644 --- a/static/components/popover.less +++ b/static/components/popover.less @@ -6,11 +6,14 @@ } .popover { + display: flex; + flex-direction: column; width: 250px; max-height:400px; background-color: @background-color; border-radius: @border-radius-base; - box-shadow: 0 4px 30px rgba(0,0,0,0.19), inset 0 0 1px rgba(0,0,0,0.5); + overflow: hidden; + box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.15), 0 -0.5px 0 rgba(0, 0, 0, 0.15), 0.5px 0 0 rgba(0, 0, 0, 0.15), -0.5px 0 0 rgba(0, 0, 0, 0.15), 0 4px 7px rgba(0,0,0,0.15), inset 0 0 1px rgba(0,0,0,0.5); .menu { z-index:1; diff --git a/static/images/labels/tagging-checkbox@2x.png b/static/images/labels/tagging-checkbox@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..00f5c14b6cc48c2ae27b5b2505fc7f298b3b171f GIT binary patch literal 1218 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 literal 0 HcmV?d00001 diff --git a/static/images/search/searchloupe@2x.png b/static/images/search/searchloupe@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d9f68ed545e06d7b49db04ea8fb82299b9bc2e52 GIT binary patch literal 1029 zcmeAS@N?(olHy`uVBq!ia0vp^@*vE?3?wxQo^b;ymUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fHRz`)oO;1l8sRM6PcK8glB z1b`6{_xQ;(Ak|b7i0BXNh-sRK0#~v-Xw1 z!tYL#?kH7!&~$yL(tV04g5`X?&k5()tGdtrv=s{72%N3G$9#(!3m&b$5TlnphBsr?T>>n+OXc5hXzHSLwS{62Rl z&^3%n-tI089jvk*Kn`btM`SSrgP1A^GkON8d;lu&@N{tu(Kx?$Qh4wo2MJekZvk!% zp*0PQlAJ{ZxjVXIlaID3CGnOWy?6TW(>}GvDYCU6?dIm6yPH!c>~!g3YU=YRMlEu+ z^AzRpYij5$%2>>)p=UTfDnk3zDeWuW+KkRq|FzG|l77Rtbe-c`Lu0Gg0rwTY>zSBo zikU24YHGam;dt^HKI-p0#u=6}sS j{{kz1pPl@@|Gd7GYv8*j(`SbQ1De6p)z4*}Q$iB}IoHI^ literal 0 HcmV?d00001 diff --git a/static/images/toolbar/ic-toolbar-movetofolder@2x.png b/static/images/toolbar/ic-toolbar-movetofolder@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7a5e54ff9a53abb2e59e49498a7a3bf641a993b4 GIT binary patch literal 14805 zcmeI3e{2(F7{{+$W?%#}Bd8#n<`!fi?cMd~dR{xnI@}^1a~p1e#@+7TZkM%pwRfXk zNn|K45=C)_fW+`4{viG#CX%Q~0*D!bm?$O&1^ht*fgmXIhd+bjd)Kag*LA#L`15Vj z_UZe6pXYh+^E~g{`{&lTwmdSua&{#}QPZ27!fo(-q4}P28~nU~-3lB0nwn@@u2a;s zJI%L^+WN*l6jfWJbaWYAktMvO#hs$8^#Er&o`7nK66(?kQR)YVy$AFvYLLFX?*MIA zq3=6nuh`6z!2@}cudv#bda{<^3XPi8QN}1 zjQ${9Zzi;NMOy6*S`yg3PMu|Yw56gR8_S}mO zRYFTh%2B>8yf~K*{tD84hLPYICY4G#Q*Ni0>}9w>Ai%IL#^rKAg+m`y4KeLd_3CVp zu{dF%OGzbRD4J?F>x!ZEhZSw$ugy!WGU~H7)Zvf zrO6TlVjvDwLx+A`Nxwv&W@vh!Hc86-^rVSlO(Kze?vm?`$4jQxjmA|FK@Mq2PrYL> z0hl(RYXeCMG_Hb|QeEom4JBGA&bUbC%%p1vX=M^Ob0+I%wL-Q+0=!nfAqhl7OLk~l zER^lC)-i@^WlQdE=ce1R!;Dpb4>4}?Vngv^d~uzrV&>)`x6SIb*Jd6q?j zWR*oRBATp32TR9*?ali0F%V^O47h`2(GYJa3aUlo$Pypbl5x=pDRHqEFbTC+UqAG#ra0m`K6-DORC?|QNZl44Ae6qt6^?4it zDQd#>h_$lcG>|g6NYX?t{A03;BDb}f!5R*DE_gs)3+jRM3EPqOwKGxplMaewRLXx>9 zfGjZMMhnZzd*b10#iI(?9r;EL98pR zz#Xiced+P!e2OPZIxe5g5t+z2W=ncO7fJ(b6I@tC zsDR)?X<%)F3yTO95L_q?tW9uX5upNt3#Eaz2`(%mR6uZ{G_W?og++u42riTc)+V^H zh)@B+h0?&<1Q!+&Dj>K}8d#g)!XiQi1Q$vJYZF{pM5ut^LTO-af(wfX6%bq~4XjOY zVG*GMf(xaAwFxdPB2++dp){~I!G%SH3J5Ng2G%CHu!v9r!G+Sm+5{IC5h@_KP#ReK zy0|I}kN*J`KJ%A?5BF8Q^2bZ?!9csz)E1$r%v_2ZdXA$0x(2^5P}BfRQ9pH46u*U{ z?$$=08eL3Lwt3Cr`i}I;OItX#jH_zgUj6dMk(KMJ-#YQ#@E1ze%(HjRc!KsHsi=AP zqv7ENJ$*5yesTBv@l|inQal?#@Wh&^;G++1xwPTc*UKLK^}Y2|oL6S=s5skPx9`u# zHti{&Q-12VFFS3khkD}|j%$a@1pQ)4vTdVwg=dYXr+ETHwtp8;(~f0Y&aB+S9sX?V z&Y7-xQ{UXsu=cW?X?14~RV+#$vnSz?mHu`-4&0Y{@~!}2c?HP&V91~ zi1*yCC3C2YFC6{!f#U;5z4YkAr@nSOY*(k8{QZvl`KMPO`|$j>#KB2{Nk?GGvBtIKQ(`}bLWC9w_h#0R$lgfxxT%>b4~x> iE84p&c1xS#)0jW(_-e+!pYJsvylif43GZIi{p^2_Fe$tM literal 0 HcmV?d00001 diff --git a/static/images/toolbar/ic-toolbar-tag@2x.png b/static/images/toolbar/ic-toolbar-tag@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d84713f47af2fa1dd92a53c0baee28923805f329 GIT binary patch literal 15167 zcmeI3e{@sj8OLw<(Lzh5+G;rz*d-g1O>%EY z+Eb4y^)MBGJAY6sB6u9==jHI9%kJMS;=ktm z>o&0%mR}^iMObg&A`GjppqpB_7VDil2ji8JPNt2LM!bGrjbZxL5kKkJPH}h})lT~i z;*n#=#W?LWh&QUOGOOQAx#_yjAl2AuYjSjMcW9mB)y6!1M8^m4QXGj#ydEE`ix|Z5 zxH{ezh6ynqmvGw+Vv~>%Z?W2OGZUn6wNx!}$P{v1qm|0lYK=y5JFbw)RfJ4V$dwYg zR;N&pB-8P~3}QFO`E>*l4u_>-rIZP_6LPIqOUM+2LLuQ5 z61Ky~kr9cHU6BYf8OK7gjv(#lXvT*Namh9Lh5AyMj;ye_XlmB_~?+3^&K2~V>_(MTYd}&SxL3t=I<>Of1PoCA!?`AlLbu%-hOij<2n6HV|n#!GZyhM#<28v}uK?ha4gTIs&*{+_WUFqUXi6qR-xOOT+&)_D^B;4Yy zkf@NJzgC?&NRb>9Y+@LXG0|oANrbrB9PcW;!b&=5pTMi!E1r%=SxAmD@*Sy` z$TSj#qDijQspUF_jQ`2^B@iT`%!pxSoV2SWdkmEvi~%S!WAHo3Npj>jiUMkZI8KMo z#RR=1XQaJkJ4N_??Rp|T2q;s1r86@gCde;~GAi{%wqd|4-fOygAIp(G2UTw|@&i(u zcIxCZrAAv#DI_Xa6)90^Nx7uDN+WzwE)A)z(x{Zxafntc=sqp9g>i(0{gMpr{Fb2s z_i3Tm2WkGSBRzmdSpBz6ktNKuRCP4VcW4LDxqSb|PqRiUFm-ft!b8VTB|jNtDPhkU z#FLvYb&RD>DZwYccXgygIM0j@VM|a>Juzi8y{wdH(vALq3z&!xxX+v;?52Fz+fa#6 zan;O0SjNSL$slEF=X>It?e;|6sg9Wm^n~ya;Gw6MA~8C(LX$l^y`p;IVAJv4z^{yq zS3#~Prj4aDC-f~sRXw4&UZarlFG4PSCSAelOxI1C?ZgRaDg=3?|))gNQPmbwqls>ISeadp4|G@9T+z2iF%8vDe}R;&KulCfkkg$ z9y$N`sg?)Dp~8|f`@!Nq&R%@dRPj(Oe(86mFT5`+~(1`1kOoL%YqPin~^| z+#1VoE^Iim_UyKI{`&XusA{G6>+r;}m5=A%aQaQdi(5vn9lz(3z5~y<72I=+!T)}P z`=c9c8@_6vU!A{X_O9+=>DZUEKFaZlOb>m0d@*}Qa=!S(C|zbfdO@_`9xMCR%9uiS z;3Uy~V9?cSF8wgj`onXN&c3%JN>u*sKUG_MPW@$Jmu9%;{n$^|?x?8Udm(4Mu);Ne zZ{z;p>4|?xWBsOQkIp(~zxjrT7k&QmtqYe+BIToVBBCW%*W4!?zS6O?{P&l?w`^|n zTvzCVa^I6Bk<&{i=KkzTq@_9bh>1BpdthNCzpIfLF1@?E_p{Xn3wCTi`-_@)7k&PW zE%!iqX?R>c{=lO-Zx2`YT-;my)kaO_C(%O(u@^Q!vF)bOmwkREJ@RABK)$!C>qPEQ zqwP6MPC#YKNWRwdi=h^wom)-B)+h9d{*93ws}Z=O1Wm?tiwYwH!i7O z$JG{HjC372``V_vrB{3RTz(V#xb5>W6{IE{QjB63;ZXGmWslmulw@$=ugQ1`&i+*yQ;@_oq7J; z_P4f0MYh?yf4#*QIMcjzt2?|0UpKmTc=rd#=k?`jht`+2N8j*oe05~t>Yuxhyq90O z{8FFk+>riq%`NMiU%E7BbZu;G@SVcl-3=@L(50An^Z0E?@fR2DzZiaF+3UYMF_DAm YJLfxpek1ji@Q7=Dt<7?%rgi840DfD}VE_OC literal 0 HcmV?d00001 diff --git a/static/images/toolbar/toolbar-star-selected@2x.png b/static/images/toolbar/toolbar-star-selected@2x.png index da0e82e067c1e3ebd9c2b19e84da3b0d76f7bf4f..ce1e7455e5dbb4353d8580e34a77697317b38990 100644 GIT binary patch delta 734 zcmV<40wMj{3(5;2iBL{Q4GJ0x0000DNk~Le0000e0000e2nGNE0F3^)ZLuN!0ZcVF zHeoq2IWa9}V=_4{G-NY4EjTw}Wi2x|WivN5Ff%kXGG!tlcx`Y^O*%wxV|8t1ZgfdR zJtBB*a4uwNWI8oCIWj>rGc__eLpPIZ0x%3UI5{#wGcz20?%&F-vYCDhzh#v#70JR)Q?I?~0Vi_QgAFNS)8Du07-vVOB!4}2# zP{+Zr`(Sh6N;C&<983;Ft^U>k;$uYE=Lp0XNpRnHpu2uxOM{@? zc!fZS&>CL@Vkvx4ECR%52gq@tP!=O54T7TbD4iX528e}7$t0kb$zD1+?kF@+NY2He zGHff29d`g47-SbSpn`Y}jU2ZFh`FeLQ4TT#@nR}FZW9o*Q?sB3m47p+sW+CLsS2h-U%u7pi%b#58yTN4ok4#0#O#HCVH42~-@L`scKHXi>w z)PJ5rZ3k|Hc7V(%%)$CVydT;?rK$sis65~qiqAZ(y}HpM;86z-000Ou0B^6;YRn+a delta 751 zcmV3Ejc+dG%Yz{IW;&nGBGkaW;r4tcx`Y^O*%wxV|8t1ZgfdR zJtBB*a4uwNWI8Z5HbXTsGBGkaF*uWI0x%3PHa0^wGBPkSIWahsrUE?yHIw85JR~qS zWH~i5IAbklV>LM~G&p5BEn#FbVl6UeG&L|cG&3#FF_nNhE!+ln9}OJL1H_MkSb!D|rbUv724Wc?jvp#X;xfosAif2} zjDtN%*h3u)!|sFK!7I@mym2r)SQ3bTqdE955X%qNOqUMCELh6_Jh$D1i_!PB5m=WEPjEn?}PJXmDhz1ZJBf@@1AihX~ zd%pwS_Jf!Z21V@^0wG3gd<}@DNGaAp>F(?RITjQPV&s&=pr}7eXUCoaVj)T@4p7T$ zFP$8F6dFhr*L0vVbSsS=djJ|ZRI8eQK^4Oq8aZ|c5OdL@Ex`=Li>d6`O+d^}%f=6= z{GCB1$F7FP9L-y6j8t&+I%tg1r7Z!n@E(ONf=AG%C*8XFHWWJ8lHQGQR-(0oozN_N5A7AR5RvrKp&e3W{V}w5a4ph! z!yITspA-NZAe@RceldmC4&DasP?}L#Lg)kWerSW8rVb9G@*sF9KJ$p`HIGgij5>JK h!J`gF0RRC80QGs?mqSf$$N&He002ovPDHLkV1jsz8chHI diff --git a/static/images/toolbar/toolbar-star@2x.png b/static/images/toolbar/toolbar-star@2x.png index 556b14552d22d5696be94861e8a3a91fc7766c3a..761d8d592b3932b659c11ff81e8f10413f31e068 100644 GIT binary patch delta 52 zcmZ3>yOwuD921YJg{6_Bv9Y0%g|qqOEG7e(fB{Hg^D-tqMwTp#fY+OEFkfW?0BcbW A!vFvP delta 52 zcmZ3>yOwuD921X$nVGYxk)ex`rJ=>-EG7e(fHO#7^D-tqMwUI6kqbB9V7|%(0BgMt A%K!iX diff --git a/static/images/toolbar/toolbar-tags@2x.png b/static/images/toolbar/toolbar-tags@2x.png deleted file mode 100644 index c0c4ae2156bae14829aa8d8136dcddc0283c8e65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1103 zcmV-V1hD&wP)Px(2}wjjR7ef2mfvd=Sro_bok?xBYW#uqLE0oWjUuw*T1tOuOeD6}wf?FRe>@1T zKKQf``?CMSzAeJCZ;J|o5>d2hOiWBtjH$9gP*N0AO>Gr1YS${YZJN1$Pju>3XRx=?)p$aA+j>WD#*RhvWaRTL; zISfEsNza+a%oLM6w_<_end;Ir)5jeVh(BdQIXeFCRXCMO<@J_r*w9(6$Vv}vO(v#9 zo84PB_rLl$%Dh^Kr!K6erDHLiyRqxv5bTKFLedd%Bxl);qqu}=HFiqCCL@1cSTY%Wjp{s&Ab)Vd z{xVuj`gGbq`QJ;64=?7&87=@Y*y?I`>8+t^gb!nhSP+{ifB1Yp`C0DmN&H_A;w8s~ z5M4KWL)Wztb$6z9n62#h`wPpe=O2Rq2`W0yc~6^b%U11rg1saK47;_4a%2J`Lx(wM ze;2EqiwlUmU5zEz+MO2U&jsUNtKE?q$`0I9jpaU{WCyNT>zq_%t@w3Y>l_aeH!U|e z{?t19KmUA1-fX}Q0@Um$p**5-JPJpT;Cj_(MJ zU{{rh>B43j(QQ7Cp}~RMHoF5|gl{eat@ZbzORM&BpEdUSL4b}SX4N!|f+5#8J_Fef ztIc_LXfX4jTu_s{vqlyyf>bU+(VUq9Q-LTN6O10ryUPXAb;P+sAZqlt(7X?P1tTC1 zH~RT-za2=|kR#6ww%p^pEBkz=tV*qwF+ zY6NhXZMh=~-BUFPo_x_#>p!sXhdiPv1y3@5?Y_od6ldFr1)A(Hho&6|YBQHiz0eAol-w46F80YBh;&-EKMH~H_V6>2mVBwNc zKY-KS9H2?g#GV`R5Pjwuj#-_Vc6Bb-U$D$uciudAVm=_t03AkJk0g47^es|yd;pVY VgyO#kK%M{q002ovPDHLkV1mPG4|M