diff --git a/core/composer.json b/core/composer.json index e8120c6..8fda381 100644 --- a/core/composer.json +++ b/core/composer.json @@ -55,6 +55,7 @@ "drupal/ckeditor": "self.version", "drupal/classy": "self.version", "drupal/color": "self.version", + "drupal/coffee": "self.version", "drupal/comment": "self.version", "drupal/config": "self.version", "drupal/config_translation": "self.version", diff --git a/core/misc/icons/bebebe/loupe.svg b/core/misc/icons/bebebe/loupe.svg new file mode 100644 index 0000000..028e5cc --- /dev/null +++ b/core/misc/icons/bebebe/loupe.svg @@ -0,0 +1 @@ + diff --git a/core/misc/icons/ffffff/loupe.svg b/core/misc/icons/ffffff/loupe.svg new file mode 100644 index 0000000..290f4db --- /dev/null +++ b/core/misc/icons/ffffff/loupe.svg @@ -0,0 +1 @@ + diff --git a/core/modules/coffee/coffee.api.php b/core/modules/coffee/coffee.api.php new file mode 100644 index 0000000..21cb1e4 --- /dev/null +++ b/core/modules/coffee/coffee.api.php @@ -0,0 +1,55 @@ + Url::fromRoute('my.simple.route')->toString(), + 'label' => 'Simple', + // Every result should include a command. + 'command' => ':simple', + ); + + // More advanced example to include view results. + if ($view = Views::getView('frontpage')) { + $view->setDisplay(); + $view->preExecute(); + $view->execute(); + + foreach ($view->result as $row) { + $entity = $row->_entity; + $commands[] = array( + 'value' => $entity->toUrl()->toString(), + 'label' => 'Pub: ' . $entity->label(), + // You can also specify commands that if the user enters, this command + // should show. + 'command' => ':x' . ' ' . $entity->label(), + ); + } + } + + return $commands; +} + +/** + * @} End of "addtogroup hooks" + */ diff --git a/core/modules/coffee/coffee.coffee.inc b/core/modules/coffee/coffee.coffee.inc new file mode 100644 index 0000000..388dd8a --- /dev/null +++ b/core/modules/coffee/coffee.coffee.inc @@ -0,0 +1,41 @@ + Url::fromRoute('')->toString(), + 'label' => t('Go to front page'), + 'command' => ':front', + ); + + // The command is ":add" and includes the link_title to empower the + // autocompletion. + $command = ':add'; + $entity_manager = \Drupal::entityTypeManager(); + // Only use node types the user has access to. + foreach ($entity_manager->getStorage('node_type')->loadMultiple() as $type) { + $node_type = $type->id(); + if ($entity_manager->getAccessControlHandler('node')->createAccess($node_type)) { + $commands[] = array( + 'value' => Url::fromRoute('node.add', ['node_type' => $node_type])->toString(), + 'label' => $type->label(), + 'command' => $command . ' ' . $type->label(), + ); + } + } + + return $commands; +} diff --git a/core/modules/coffee/coffee.info.yml b/core/modules/coffee/coffee.info.yml new file mode 100644 index 0000000..9b48453 --- /dev/null +++ b/core/modules/coffee/coffee.info.yml @@ -0,0 +1,7 @@ +name: Coffee +description: 'Provides an Alfred like search box to navigate within your site.' +package: core +version: VERSION +core: 8.x +type: module +configure: coffee.configuration diff --git a/core/modules/coffee/coffee.libraries.yml b/core/modules/coffee/coffee.libraries.yml new file mode 100644 index 0000000..8733abe --- /dev/null +++ b/core/modules/coffee/coffee.libraries.yml @@ -0,0 +1,14 @@ +drupal.coffee: + version: VERSION + js: + js/coffee.js: {} + css: + component: + css/coffee.css: {} + dependencies: + - core/jquery + - core/drupal + - core/drupalSettings + - core/jquery.once + - core/jquery.ui + - core/jquery.ui.autocomplete diff --git a/core/modules/coffee/coffee.links.menu.yml b/core/modules/coffee/coffee.links.menu.yml new file mode 100644 index 0000000..1f71c77 --- /dev/null +++ b/core/modules/coffee/coffee.links.menu.yml @@ -0,0 +1,5 @@ +coffee.configuration: + title: Coffee + description: 'Select what menus coffee should search.' + route_name: coffee.configuration + parent: system.admin_config_ui diff --git a/core/modules/coffee/coffee.module b/core/modules/coffee/coffee.module new file mode 100644 index 0000000..1d08f3b --- /dev/null +++ b/core/modules/coffee/coffee.module @@ -0,0 +1,94 @@ +' . t('About') . ''; + $output .= '

' . t('The Coffee module offers quick navigation functionality as well as an extensible set of commands to let you jump around your site to desired pages very fast. The functionality is inspired by Alfred and Spotlight on Mac OS X. To activate the feature, either press the Coffee button in the toolbar or use the Alt-D keyboard shortcut (Alt + Shift + D in Opera, Alt + Ctrl + D in Windows Internet Explorer). For more information, see the online documentation for the Coffee module.', array(':coffee' => 'https://www.drupal.org/documentation/modules/coffee')) . '

'; + $output .= '

' . t('Uses') . '

'; + $output .= '
'; + $output .= '
' . t('Jumping to pages in the administration menu') . '
'; + $output .= '
' . t('Start typing either part of the path or the title of an administration page you want to jump to. If the first result is the one you were looking for, just hit enter. If not, either continue typing or pick the right result from the list. You can also use your keyboard cursor keys to pick the desired result.') . '
'; + $output .= '
' . t('Configuring additional menus and number of results') . '
'; + $output .= '
' . t('Coffee only searches in the administration menu by default. You can configure support for any other menu on the Coffee configuration page. It is also possible to set the number of displayed results to less or more than the default 7.', array(':admin-coffee' => Url::fromRoute('coffee.configuration')->toString())) . '
'; + $output .= '
' . t('Additional commands') . '
'; + $output .= '
' . t('Coffee also supports an extensible set of commands. The two built-in commands are :front which results in the front page and :add which provides a list of all content types to create new content with. The :add command may receive the name of the content type as well, therefore :add:Article also works. Other modules may provide further commands.') . '
'; + $output .= '
' . t('Permissions') . '
'; + $output .= '
' . t('Users with the Administer Coffee permission can configure available menus and number of result, while the Access Coffee permission grants the use of the feature itself to users.') . '
'; + $output .= '
'; + return $output; + } +} + +/** + * Implements hook_page_attachments(). + */ +function coffee_page_attachments(array &$attachments) { + if (\Drupal::currentUser()->hasPermission('access coffee')) { + $config = \Drupal::config('coffee.configuration'); + $cache_tags = isset($attachments['#cache']['tags']) ? $attachments['#cache']['tags'] : []; + $attachments['#cache']['tags'] = Cache::mergeTags($cache_tags, $config->getCacheTags()); + + $attachments['#attached']['library'][] = 'coffee/drupal.coffee'; + $attachments['#attached']['drupalSettings']['coffee'] = [ + 'maxResults' => $config->get('max_results'), + ]; + } +} + +/** + * Implements hook_hook_info(). + */ +function coffee_hook_info() { + $hooks = array( + 'coffee_commands' => array( + 'group' => 'coffee', + ), + ); + + return $hooks; +} + +/** + * Implements hook_toolbar(). + */ +function coffee_toolbar() { + $items['coffee'] = [ + '#cache' => [ + 'contexts' => ['user.permissions'], + ], + ]; + + if (\Drupal::currentUser()->hasPermission('access coffee')) { + $items['coffee'] += [ + '#weight' => 999, + '#type' => 'toolbar_item', + 'tab' => [ + '#type' => 'link', + '#title' => t('Go to'), + '#url' => Url::fromRoute(''), + '#attributes' => [ + 'title' => t('Use alt+d to start Coffee and search for a page to go to '), + 'class' => ['toolbar-icon', 'toolbar-icon-coffee'], + ], + ], + '#attached' => [ + 'library' => ['coffee/drupal.coffee'], + ], + ]; + } + + return $items; +} diff --git a/core/modules/coffee/coffee.permissions.yml b/core/modules/coffee/coffee.permissions.yml new file mode 100644 index 0000000..98a7d2f --- /dev/null +++ b/core/modules/coffee/coffee.permissions.yml @@ -0,0 +1,7 @@ +access coffee: + title: 'Access Coffee' + description: 'Access the Coffee search box to navigate fast between admin pages' + +administer coffee: + title: 'Administer Coffee' + description: 'Administer the Coffee search module' diff --git a/core/modules/coffee/coffee.routing.yml b/core/modules/coffee/coffee.routing.yml new file mode 100644 index 0000000..bd27ac8 --- /dev/null +++ b/core/modules/coffee/coffee.routing.yml @@ -0,0 +1,14 @@ +coffee.configuration: + path: '/admin/config/user-interface/coffee' + defaults: + _form: 'Drupal\coffee\Form\CoffeeConfigurationForm' + _title: 'Coffee configuration' + requirements: + _permission: 'administer coffee' + +coffee.get_data: + path: '/admin/coffee/get-data' + defaults: + _controller: 'Drupal\coffee\Controller\CoffeeController::coffeeData' + requirements: + _permission: 'access coffee' diff --git a/core/modules/coffee/config/install/coffee.configuration.yml b/core/modules/coffee/config/install/coffee.configuration.yml new file mode 100644 index 0000000..af6e3d6 --- /dev/null +++ b/core/modules/coffee/config/install/coffee.configuration.yml @@ -0,0 +1,3 @@ +coffee_menus: + admin: admin +max_results: 7 diff --git a/core/modules/coffee/config/schema/coffee.schema.yml b/core/modules/coffee/config/schema/coffee.schema.yml new file mode 100644 index 0000000..b8664dd --- /dev/null +++ b/core/modules/coffee/config/schema/coffee.schema.yml @@ -0,0 +1,15 @@ +# Schema for the configuration files of the Coffee module. + +coffee.configuration: + type: config_object + label: 'Coffee settings' + mapping: + coffee_menus: + type: sequence + label: 'Coffee menus' + sequence: + - type: string + label: 'Menu' + max_results: + type: integer + label: 'Number of items to show in the search result' diff --git a/core/modules/coffee/css/coffee.css b/core/modules/coffee/css/coffee.css new file mode 100644 index 0000000..f37ede7 --- /dev/null +++ b/core/modules/coffee/css/coffee.css @@ -0,0 +1,177 @@ +/** + * @file + * Stylesheet for the Coffee module. + */ +[id^="coffee"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +#coffee-bg { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 999; +} + +.coffee-form-wrapper{ + pointer-events: none; + left: 0; + width: 100%; + position: fixed; + top: 20%; + z-index: 9999; +} +#coffee-form { + pointer-events: auto; + position:relative; + margin: 0 auto; + max-width: 500px; + width: 100%; + padding: 10px; + font: 16px/1.5 sans-serif; + background: black; + background: rgba(0, 0, 0, 0.4); + -webkit-border-radius: 15px; + -moz-border-radius: 15px; + -ms-border-radius: 15px; + -o-border-radius: 15px; + border-radius: 15px; + opacity: 1; + -webkit-transition: opacity 0.35s; + -moz-transition: opacity 0.35s; + -ms-transition: opacity 0.35s; + -o-transition: opacity 0.35s; + transition: opacity 0.35s false; +} + +#coffee-form, +#coffee-form input, +#coffee-form a{ + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.coffee-form-wrapper.hide-form { + position: absolute; + left: -100%; + opacity: 0; +} + +#coffee-form-inner { + background: #fff; + color: #444; + padding: 10px 10px 1px; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + -ms-border-radius: 5px; + -o-border-radius: 5px; + border-radius: 5px; + -webkit-box-shadow: 0px 10px 40px rgba(0, 0, 0, 0.6); + -moz-box-shadow: 0px 10px 40px rgba(0, 0, 0, 0.6); + box-shadow: 0px 10px 40px rgba(0, 0, 0, 0.6); +} + +#coffee-q { + background: #d1d1d1; + color: #000; + border: 0; + font: 36px sans-serif; + line-height: 52px; + padding: 5px 10px; + width: 100%; + height: 52px; + outline: none; + display: block; + margin: 0 0 10px; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + -ms-border-radius: 0px; + -o-border-radius: 0px; + border-radius: 0px; +} + +#coffee-q:focus { + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} + +#coffee-results ul { + position: static; + float: none; + list-style: none; + margin: 0; + padding: 10px 0 0; + border: 0; +} + +#coffee-results li { + margin: 0; + padding: 0; + float: none; + clear: none; + width: auto; +} + +#coffee-results a { + display: block; + border: 0; + outline: none; + color: #444; + padding: 6px 10px 4px; + line-height: normal; + font-size: 16px; + text-decoration: none; + height: auto; + -webkit-transition: none; + -moz-transition: none; + -ms-transition: none; + -o-transition: none; + transition: none; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + -ms-border-radius: 0px; + -o-border-radius: 0px; + border-radius: 0px; +} + +#coffee-results a.ui-state-hover, +#coffee-results a:focus, +#coffee-results a:hover, +#coffee-results .ui-state-focus { + background: #d1d1d1; + color: #000; + margin: 0; + cursor: pointer; +} + +#coffee-results .description { + display: block; + font-size: 11px; + color: #888; +} + +#coffee-results a.ui-state-hover .description { + color: #666; +} + +#coffee-results .ui-widget { + font-family: sans-serif; +} + +.toolbar .toolbar-bar .toolbar-icon-coffee:before, +.toolbar .toolbar-bar .toolbar-icon-coffee.active:before { + background-image: url(../../../misc/icons/ffffff/loupe.svg); +} + +#coffee-q{ + background-image: url(../../../misc/icons/bebebe/loupe.svg); + background-position: right 10px center; + background-repeat: no-repeat; + background-size: 40px 40px; +} diff --git a/core/modules/coffee/js/coffee.js b/core/modules/coffee/js/coffee.js new file mode 100644 index 0000000..14297c3 --- /dev/null +++ b/core/modules/coffee/js/coffee.js @@ -0,0 +1,230 @@ +/** + * @file + * JavaScript file for the Coffee module. + */ + +(function ($, Drupal, drupalSettings, DrupalCoffee) { + + 'use strict'; + + // Remap the filter functions for autocomplete to recognise the + // extra value "command". + var proto = $.ui.autocomplete.prototype; + var initSource = proto._initSource; + + function filter(array, term) { + var matcher = new RegExp($.ui.autocomplete.escapeRegex(term), 'i'); + return $.grep(array, function (value) { + return matcher.test(value.command) || matcher.test(value.label) || matcher.test(value.value); + }); + } + + $.extend(proto, { + _initSource: function () { + if ($.isArray(this.options.source)) { + this.source = function (request, response) { + response(filter(this.options.source, request.term)); + }; + } + else { + initSource.call(this); + } + } + }); + + /** + * Coffee module namespace + * + * @namespace + * + * @todo put this in Drupal.coffee to expose it. + */ + DrupalCoffee = DrupalCoffee || {}; + + /** + * Attaches coffee module behaviors. + * + * Initializes DOM elements coffee module needs to display the search. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attach coffee functionality to the page. + * + * @todo get most of it out of the behavior in dedicated functions. + */ + Drupal.behaviors.coffee = { + attach: function () { + $('body').once('coffee').each(function () { + var body = $(this); + DrupalCoffee.bg.appendTo(body).hide(); + DrupalCoffee.wrapper.appendTo('body').addClass('hide-form'); + DrupalCoffee.form + .append(DrupalCoffee.label) + .append(DrupalCoffee.field) + .append(DrupalCoffee.results) + .wrapInner('
') + .appendTo(DrupalCoffee.wrapper); + + // Load autocomplete data set, consider implementing + // caching with local storage. + DrupalCoffee.dataset = []; + DrupalCoffee.isItemSelected = false; + + var autocomplete_data_element = 'ui-autocomplete'; + + $.ajax({ + url: Drupal.url('admin/coffee/get-data'), + dataType: 'json', + success: function (data) { + DrupalCoffee.dataset = data; + + // Apply autocomplete plugin on show + var $autocomplete = $(DrupalCoffee.field).autocomplete({ + source: DrupalCoffee.dataset, + focus: function (event, ui) { + // Prevents replacing the value of the input field + DrupalCoffee.isItemSelected = true; + event.preventDefault(); + }, + change: function (event, ui) { + DrupalCoffee.isItemSelected = false; + }, + select: function (event, ui) { + DrupalCoffee.redirect(ui.item.value, event.metaKey); + event.preventDefault(); + return false; + }, + delay: 0, + appendTo: DrupalCoffee.results + }); + + $autocomplete.data(autocomplete_data_element)._renderItem = function (ul, item) { + // strip the basePath when displaying the link description + var description = item.value; + if (item.value.indexOf(drupalSettings.path.basePath) === 0) { + description = item.value.substring(drupalSettings.path.basePath.length); + } + return $('
  • ') + .data('item.autocomplete', item) + .append('' + item.label + '' + description + '') + .appendTo(ul); + }; + + // We want to limit the number of results. + $(DrupalCoffee.field).data(autocomplete_data_element)._renderMenu = function (ul, items) { + var self = this; + items = items.slice(0, drupalSettings.coffee.maxResults); + $.each(items, function (index, item) { + self._renderItemData(ul, item); + }); + }; + + DrupalCoffee.form.keydown(function (event) { + if (event.keyCode === 13) { + var openInNewWindow = false; + + if (event.metaKey) { + openInNewWindow = true; + } + + if (!DrupalCoffee.isItemSelected) { + var $firstItem = $(DrupalCoffee.results).find('li:first').data('item.autocomplete'); + if (typeof $firstItem === 'object') { + DrupalCoffee.redirect($firstItem.value, openInNewWindow); + event.preventDefault(); + } + } + } + }); + }, + error: function () { + DrupalCoffee.field.val('Could not load data, please refresh the page'); + } + }); + + $('.toolbar-icon-coffee').click(function (event) { + event.preventDefault(); + DrupalCoffee.coffee_show(); + }); + // Key events + $(document).keydown(function (event) { + + // Show the form with alt + D. Use 2 keycodes as 'D' can be uppercase or lowercase. + if (DrupalCoffee.wrapper.hasClass('hide-form') && + event.altKey === true && + // 68/206 = d/D, 75 = k. + (event.keyCode === 68 || event.keyCode === 206 || event.keyCode === 75)) { + DrupalCoffee.coffee_show(); + event.preventDefault(); + } + // Close the form with esc or alt + D. + else { + if (!DrupalCoffee.wrapper.hasClass('hide-form') && (event.keyCode === 27 || (event.altKey === true && (event.keyCode === 68 || event.keyCode === 206)))) { + DrupalCoffee.coffee_close(); + event.preventDefault(); + } + } + }); + }); + } + }; + + // Prefix the open and close functions to avoid + // conflicts with autocomplete plugin. + + /** + * Open the form and focus on the search field. + */ + DrupalCoffee.coffee_show = function () { + DrupalCoffee.wrapper.removeClass('hide-form'); + DrupalCoffee.bg.show(); + DrupalCoffee.field.focus(); + $(DrupalCoffee.field).autocomplete({enable: true}); + }; + + /** + * Close the form and destroy all data. + */ + DrupalCoffee.coffee_close = function () { + DrupalCoffee.field.val(''); + DrupalCoffee.wrapper.addClass('hide-form'); + DrupalCoffee.bg.hide(); + $(DrupalCoffee.field).autocomplete({enable: false}); + }; + + /** + * Close the Coffee form and redirect. + * + * @param {string} path + * URL to redirect to. + * @param {bool} openInNewWindow + * Indicates if the URL should be open in a new window. + */ + DrupalCoffee.redirect = function (path, openInNewWindow) { + DrupalCoffee.coffee_close(); + + if (openInNewWindow) { + window.open(path); + } + else { + document.location = path; + } + }; + + /** + * The HTML elements. + * + * @todo use Drupal.theme. + */ + DrupalCoffee.label = $('