diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt
index fff63c1..d937411 100644
--- a/core/MAINTAINERS.txt
+++ b/core/MAINTAINERS.txt
@@ -173,6 +173,9 @@ Block module
Book module
- Peter Wolanin 'pwolanin' http://drupal.org/user/49851
+Breakpoint module
+- Peter Droogmans 'attiks' http://drupal.org/user/105002
+
Color module
- ?
diff --git a/core/modules/breakpoint/breakpoint.info b/core/modules/breakpoint/breakpoint.info
new file mode 100644
index 0000000..fee160f
--- /dev/null
+++ b/core/modules/breakpoint/breakpoint.info
@@ -0,0 +1,7 @@
+name = Breakpoint
+description = Manage breakpoints and breakpoint groups for responsive designs.
+package = Core
+version = VERSION
+core = 8.x
+
+dependencies[] = config
diff --git a/core/modules/breakpoint/breakpoint.module b/core/modules/breakpoint/breakpoint.module
new file mode 100644
index 0000000..c90ffcd
--- /dev/null
+++ b/core/modules/breakpoint/breakpoint.module
@@ -0,0 +1,423 @@
+' . t('About') . '';
+ $output .= '
' . t('The Breakpoint module allows the management of breakpoints and breakpoint groups for responsive designs.') . '
';
+ $output .= '
' . t('Uses') . '
';
+ $output .= '
';
+ $output .= '
' . t('Breakpoints') . '
';
+ $output .= '
' . t('Breakpoints can be defined by themes or other modules, a breakpoints consists of a name and a media query.') . '
';
+ $output .= '
' . t('Breakpoint groups') . '
';
+ $output .= '
' . t('Breakpoints are organized into breakpoint groups so that they are easier to manage and use them.') . '
';
+ $output .= '
' . t('Multipliers') . '
';
+ $output .= '
' . t('Multipliers can be defined for each breakpoint and are needed to handle screens with high dpi.') . '
';
+ $output .= '
';
+
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_enable().
+ *
+ * Import breakpoints from all enabled themes.
+ */
+function breakpoint_enable() {
+ // Import breakpoints from themes.
+ $themes = list_themes();
+ _breakpoint_theme_enabled(array_keys($themes));
+
+ // Import breakpoints from modules.
+ $modules = module_list();
+ _breakpoint_modules_enabled(array_keys($modules));
+}
+
+/**
+ * Implements hook_themes_enabled().
+ *
+ * @param array $theme_list
+ * An array of theme names.
+ *
+ * @see _breakpoint_theme_enabled()
+ */
+function breakpoint_themes_enabled($theme_list) {
+ _breakpoint_theme_enabled($theme_list);
+}
+
+/**
+ * Implements hook_themes_disabled().
+ *
+ * @param array $theme_list
+ * An array of theme names.
+ *
+ * @see _breakpoint_delete_breakpoints()
+ */
+function breakpoint_themes_disabled($theme_list) {
+ _breakpoint_delete_breakpoints($theme_list, Breakpoint::SOURCE_TYPE_THEME);
+}
+
+/**
+ * Implements hook_modules_enabled().
+ *
+ * @param array $modules
+ * An array of the modules that were enabled.
+ *
+ * @see _breakpoint_modules_enabled()
+ */
+function breakpoint_modules_enabled($modules) {
+ _breakpoint_modules_enabled($modules);
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ *
+ * @param array $modules
+ * An array of the modules that were uninstalled.
+ *
+ * @see _breakpoint_delete_breakpoints()
+ */
+function breakpoint_modules_uninstalled($modules) {
+ _breakpoint_delete_breakpoints($modules, Breakpoint::SOURCE_TYPE_MODULE);
+}
+
+/**
+ * Import breakpoints from all new enabled themes.
+ *
+ * @param array $theme_list
+ * An array of theme names.
+ */
+function _breakpoint_theme_enabled($theme_list) {
+ $themes = list_themes();
+ foreach ($theme_list as $theme_key) {
+ if ($themes[$theme_key]->status) {
+ $media_queries = breakpoint_get_theme_media_queries($theme_key);
+ _breakpoint_import_media_queries($theme_key, $themes[$theme_key]->info['name'], Breakpoint::SOURCE_TYPE_THEME, $media_queries);
+ // Import custom groups.
+ _breakpoint_import_breakpoint_groups($theme_key, Breakpoint::SOURCE_TYPE_THEME);
+ }
+ }
+}
+
+/**
+ * Import breakpoints from all new enabled modules.
+ *
+ * @param array $modules
+ * An array of the modules that were enabled.
+ */
+function _breakpoint_modules_enabled($modules) {
+ foreach ($modules as $module) {
+ $media_queries = breakpoint_get_module_media_queries($module);
+ _breakpoint_import_media_queries($module, $module, Breakpoint::SOURCE_TYPE_MODULE, $media_queries);
+ // Import custom groups.
+ _breakpoint_import_breakpoint_groups($module, Breakpoint::SOURCE_TYPE_MODULE);
+ }
+}
+
+/**
+ * Import media queries from a theme or module and create a default group.
+ *
+ * @param string $group_name
+ * Machine readable name of the breakpoint group.
+ * @param string $label
+ * Human readable name of the breakpoint group.
+ * @param string $sourceType
+ * Either Breakpoint::SOURCE_TYPE_THEME or Breakpoint::SOURCE_TYPE_MODULE.
+ * @param array $media_queries
+ * An array of breakpoints in the form $breakpoint['name'] = 'media query'.
+ */
+function _breakpoint_import_media_queries($group_name, $label, $source_type, $media_queries) {
+ if (!empty($media_queries)) {
+ // Create a new breakpoint group if it doesn't exist.
+ $breakpoint_group = _breakpoint_group_create_or_load($group_name, $label, $group_name, $source_type);
+
+ // Load all media queries, create a breakpoint for each one and add them
+ // to this breakpoint group.
+ foreach ($media_queries as $name => $media_query) {
+ $breakpoint_group->addBreakpointFromMediaQuery($name, $media_query);
+ }
+
+ $breakpoint_group->save();
+ }
+}
+
+/**
+ * Import breakpoint groups from theme or module.
+ *
+ * @param string $source
+ * The theme or module name
+ * @param string $sourceType
+ * Either Breakpoint::SOURCE_TYPE_THEME or Breakpoint::SOURCE_TYPE_MODULE.
+ */
+function _breakpoint_import_breakpoint_groups($source, $source_type) {
+ $breakpoint_groups = config($source . '.breakpoint_groups');
+ if ($breakpoint_groups) {
+ foreach ($breakpoint_groups->get() as $group_name => $data) {
+ // Breakpoints is mandatory, extra check since this is coming from config.
+ if (isset($data['breakpoints']) && !empty($data['breakpoints'])) {
+ // Create a new breakpoint group if it doesn't exist.
+ $breakpoint_group = _breakpoint_group_create_or_load($group_name, isset($data['label']) ? $data['label'] : $group_name, $source, $source_type);
+ // Add the breakpoints.
+ $breakpoint_group->addBreakpoints($data['breakpoints']);
+ $breakpoint_group->save();
+ }
+ else {
+ throw new \Exception('Illegal config file detected.');
+ }
+ }
+ }
+}
+
+/**
+ * Remove breakpoints from all disabled themes or uninstalled modules.
+ *
+ * The source type has to match the original source type, otherwise the group
+ * will not be deleted. All groups created by the theme or module will be
+ * deleted as well.
+ *
+ * @param array $list
+ * A list of modules or themes that are disabled.
+ * @param string $sourceType
+ * Either Breakpoint::SOURCE_TYPE_THEME or Breakpoint::SOURCE_TYPE_MODULE.
+ */
+function _breakpoint_delete_breakpoints($list, $source_type) {
+ $ids = config_get_storage_names_with_prefix('breakpoint.breakpoint_group.' . $source_type . '.');
+ $entity_info = entity_get_info('breakpoint_group');
+
+ // Remove the breakpoint.breakpoint part of the breakpoint identifier.
+ foreach ($ids as &$id) {
+ $id = drupal_substr($id, drupal_strlen($entity_info['config prefix']) + 1);
+ }
+ $breakpoint_groups = entity_load_multiple('breakpoint_group', $ids);
+
+ foreach ($breakpoint_groups as $breakpoint_group) {
+ if ($breakpoint_group->sourceType == $source_type && in_array($breakpoint_group->source, $list)) {
+ // Delete the automatically created breakpoint group.
+ $breakpoint_group->delete();
+
+ // Get all breakpoints defined by this theme/module.
+ $breakpoint_ids = drupal_container()->get('config.storage')->listAll('breakpoint.breakpoint.' . $source_type . '.' . $breakpoint_group->id() . '.');
+ $entity_info = entity_get_info('breakpoint');
+
+ // Remove the breakpoint.breakpoint part of the breakpoint identifier.
+ foreach ($breakpoint_ids as &$breakpoint_id) {
+ $breakpoint_id = drupal_substr($breakpoint_id, drupal_strlen($entity_info['config prefix']) + 1);
+ }
+ $breakpoints = entity_load_multiple('breakpoint', $breakpoint_ids);
+
+ // Make sure we only delete breakpoints defined by this theme/module.
+ foreach ($breakpoints as $breakpoint) {
+ if ($breakpoint->sourceType == $source_type && $breakpoint->source == $breakpoint_group->name) {
+ $breakpoint->delete();
+ }
+ }
+ }
+ }
+
+ // Delete groups defined by a module/theme even if that module/theme didn't
+ // define any breakpoints.
+ foreach ($ids as $id) {
+ // Delete all breakpoint groups defined by the theme or module.
+ _breakpoint_delete_breakpoint_groups($id, $source_type);
+ }
+}
+
+/**
+ * Remove breakpoint groups from all disabled themes or uninstalled modules.
+ *
+ * @param array $group_id
+ * Machine readable name of the breakpoint group.
+ * @param string $sourceType
+ * Either Breakpoint::SOURCE_TYPE_THEME or Breakpoint::SOURCE_TYPE_MODULE.
+ */
+function _breakpoint_delete_breakpoint_groups($group_id, $source_type) {
+ $breakpoint_groups = entity_load_multiple('breakpoint_group');
+ foreach ($breakpoint_groups as $breakpoint_group) {
+ if ($breakpoint_group->sourceType == $source_type && $breakpoint_group->source == $group_id) {
+ $breakpoint_group->delete();
+ }
+ }
+}
+
+/**
+ * Get a list of available breakpoints from a specified theme.
+ *
+ * @param string $theme_key
+ * The name of the theme.
+ *
+ * @return array
+ * An array of breakpoints in the form $breakpoint['name'] = 'media query'.
+ */
+function breakpoint_get_theme_media_queries($theme_key) {
+ $themes = list_themes();
+ if (!isset($themes[$theme_key])) {
+ throw new \Exception('Illegal theme_key passed.');
+ }
+
+ $config = config($theme_key . '.breakpoints');
+ if ($config) {
+ return $config->get();
+ }
+ return array();
+}
+
+/**
+ * Get a list of available breakpoints from a specified module.
+ *
+ * @param string $module
+ * The name of the module.
+ *
+ * @return array
+ * An array of breakpoints in the form $breakpoint['name'] = 'media query'.
+ */
+function breakpoint_get_module_media_queries($module) {
+ if (!module_exists($module)) {
+ throw new \Exception('Illegal module name passed.');
+ }
+
+ $config = config($module . '.breakpoints');
+ if ($config) {
+ return $config->get();
+ }
+ return array();
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function breakpoint_entity_info() {
+ // Breakpoint.
+ $types['breakpoint'] = array(
+ 'label' => 'Breakpoint',
+ 'entity class' => 'Drupal\breakpoint\Breakpoint',
+ 'controller class' => 'Drupal\Core\Config\Entity\ConfigStorageController',
+ 'config prefix' => 'breakpoint.breakpoint',
+ 'entity keys' => array(
+ 'id' => 'id',
+ 'label' => 'label',
+ 'uuid' => 'uuid',
+ ),
+ );
+
+ // Breakpoint group.
+ $types['breakpoint_group'] = array(
+ 'label' => 'Breakpoint group',
+ 'entity class' => 'Drupal\breakpoint\BreakpointGroup',
+ 'controller class' => 'Drupal\Core\Config\Entity\ConfigStorageController',
+ 'config prefix' => 'breakpoint.breakpoint_group',
+ 'entity keys' => array(
+ 'id' => 'id',
+ 'label' => 'label',
+ 'uuid' => 'uuid',
+ ),
+ );
+
+ return $types;
+}
+
+/**
+ * Load one breakpoint group by its identifier.
+ *
+ * @param string $id
+ * The id of the breakpoint group to load.
+ *
+ * @return Drupal\breakpoint\BreakpointGroup|false
+ * The breakpoint group, or FALSE if there is no entity with the given id.
+ *
+ * @todo Remove this in a follow-up issue.
+ * @see http://drupal.org/node/1798214
+ */
+function breakpoint_group_load($id) {
+ return entity_load('breakpoint_group', $id);
+}
+
+/**
+ * Load one breakpoint by its identifier.
+ *
+ * @param int $id
+ * The id of the breakpoint to load.
+ *
+ * @return Drupal\breakpoint\Breakpoint
+ * The entity object, or FALSE if there is no entity with the given id.
+ *
+ * @todo Remove this in a follow-up issue.
+ * @see http://drupal.org/node/1798214
+ */
+function breakpoint_load($id) {
+ return entity_load('breakpoint', $id);
+}
+
+/**
+ * Load all breakpoint groups as select options.
+ *
+ * @return array
+ * An array containing breakpoint group labels indexed by their ids.
+ */
+function breakpoint_group_labels() {
+ $options = array();
+ $breakpoint_groups = entity_load_multiple('breakpoint_group');
+ foreach ($breakpoint_groups as $breakpoint_group) {
+ $options[$breakpoint_group->id()] = $breakpoint_group->label();
+ }
+ asort($options);
+ return $options;
+}
+
+/**
+ * Load all breakpoints as select options.
+ *
+ * @return array
+ * An array containing breakpoints indexed by their ids.
+ */
+function breakpoint_labels() {
+ $options = array();
+ $breakpoints = entity_load_multiple('breakpoint');
+ foreach ($breakpoints as $breakpoint) {
+ $options[$breakpoint->id()] = $breakpoint->label() . ' (' . $breakpoint->source . ' - ' . $breakpoint->sourceType . ') [' . $breakpoint->mediaQuery . ']';
+ }
+
+ return $options;
+}
+
+/**
+ * Helper function to easily create/load a breakpoint group.
+ *
+ * @param string $name
+ * Machine readable name of the breakpoint group.
+ * @param string $label
+ * Human readable name of the breakpoint group.
+ * @param string $source
+ * Machine readable name of the defining theme or module.
+ * @param string $sourceType
+ * Either Breakpoint::SOURCE_TYPE_THEME or Breakpoint::SOURCE_TYPE_MODULE.
+ *
+ * @return Drupal\breakpoint\BreakpointGroup
+ */
+function _breakpoint_group_create_or_load($name, $label, $source, $source_type) {
+ // Try loading the breakpoint group.
+ $breakpoint_group = entity_load('breakpoint_group', $source_type . '.' . $source . '.' . $name);
+ // Create a new breakpoint group if it doesn't exist.
+ if (!$breakpoint_group) {
+ // Build a new breakpoint group.
+ $breakpoint_group = entity_create('breakpoint_group', array(
+ 'name' => $name,
+ 'label' => $label,
+ 'source' => $source,
+ 'sourceType' => $source_type,
+ ));
+ }
+ return $breakpoint_group;
+}
diff --git a/core/modules/breakpoint/config/breakpoint.yml b/core/modules/breakpoint/config/breakpoint.yml
new file mode 100644
index 0000000..0ba703d
--- /dev/null
+++ b/core/modules/breakpoint/config/breakpoint.yml
@@ -0,0 +1,2 @@
+multipliers: [1x, 1.5x, 2x]
+
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/Breakpoint.php b/core/modules/breakpoint/lib/Drupal/breakpoint/Breakpoint.php
new file mode 100644
index 0000000..f9cabfd
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/Breakpoint.php
@@ -0,0 +1,295 @@
+isValid()) {
+ throw new InvalidBreakpointException('Invalid data detected.');
+ }
+
+ // Build an id if none is set.
+ // Since a particular name can be used by multiple theme/modules we need
+ // to make a unique id.
+ if (empty($this->id)) {
+ $this->id = $this->sourceType . '.' . $this->source . '.' . $this->name;
+ }
+
+ // Set the label if none is set.
+ if (empty($this->label)) {
+ $this->label = $this->name;
+ }
+
+ // Remove unused multipliers.
+ $this->multipliers = array_filter($this->multipliers);
+
+ // Always add '1x' multiplier, use array_key_exists since the value might
+ // be NULL.
+ if (!array_key_exists('1x', $this->multipliers)) {
+ $this->multipliers = array('1x' => '1x') + $this->multipliers;
+ }
+ return parent::save();
+ }
+
+ /**
+ * Duplicates a breakpoint.
+ *
+ * The new breakpoint inherits the media query.
+ *
+ * @return Drupal\breakpoint\Breakpoint
+ */
+ public function duplicate() {
+ return entity_create('breakpoint', array(
+ 'mediaQuery' => $this->mediaQuery,
+ ));
+ }
+
+ /**
+ * Checks if the breakpoint is valid.
+ *
+ * @throws Drupal\breakpoint\InvalidBreakpointSourceTypeException
+ * @throws Drupal\breakpoint\InvalidBreakpointSourceException
+ * @throws Drupal\breakpoint\InvalidBreakpointNameException
+ * @throws Drupal\breakpoint\InvalidBreakpointMediaQueryException
+ *
+ * @see isValidMediaQuery()
+ */
+ public function isValid() {
+ // Check for illegal values in breakpoint source type.
+ if (!in_array($this->sourceType, array(
+ Breakpoint::SOURCE_TYPE_CUSTOM,
+ Breakpoint::SOURCE_TYPE_MODULE,
+ Breakpoint::SOURCE_TYPE_THEME)
+ )) {
+ throw new InvalidBreakpointSourceTypeException(format_string('Invalid source type @source_type', array(
+ '@source_type' => $this->sourceType,
+ )));
+ }
+ // Check for illegal characters in breakpoint source.
+ if (preg_match('/[^a-z_]+/', $this->source)) {
+ throw new InvalidBreakpointSourceException(format_string("Invalid value '@source' for breakpoint source property. Breakpoint source property can only contain lowercase letters and underscores.", array('@source' => $this->source)));
+ }
+ // Check for illegal characters in breakpoint names.
+ if (preg_match('/[^0-9a-z_\-]/', $this->name)) {
+ throw new InvalidBreakpointNameException(format_string("Invalid value '@name' for breakpoint name property. Breakpoint name property can only contain lowercase alphanumeric characters, underscores (_), and hyphens (-).", array('@name' => $this->name)));
+ }
+ return $this::isValidMediaQuery($this->mediaQuery);
+ }
+
+ /**
+ * Checks if a mediaQuery is valid.
+ *
+ * @throws Drupal\breakpoint\InvalidBreakpointMediaQueryException
+ *
+ * @return true
+ * Returns true if the media query is valid.
+ *
+ * @see http://www.w3.org/TR/css3-mediaqueries/
+ * @see http://www.w3.org/Style/CSS/Test/MediaQueries/20120229/reports/implement-report.html
+ * @see https://github.com/adobe/webkit/blob/master/Source/WebCore/css/
+ */
+ public static function isValidMediaQuery($media_query) {
+ $media_features = array(
+ 'width' => 'length', 'min-width' => 'length', 'max-width' => 'length',
+ 'height' => 'length', 'min-height' => 'length', 'max-height' => 'length',
+ 'device-width' => 'length', 'min-device-width' => 'length', 'max-device-width' => 'length',
+ 'device-height' => 'length', 'min-device-height' => 'length', 'max-device-height' => 'length',
+ 'orientation' => array('portrait', 'landscape'),
+ 'aspect-ratio' => 'ratio', 'min-aspect-ratio' => 'ratio', 'max-aspect-ratio' => 'ratio',
+ 'device-aspect-ratio' => 'ratio', 'min-device-aspect-ratio' => 'ratio', 'max-device-aspect-ratio' => 'ratio',
+ 'color' => 'integer', 'min-color' => 'integer', 'max-color' => 'integer',
+ 'color-index' => 'integer', 'min-color-index' => 'integer', 'max-color-index' => 'integer',
+ 'monochrome' => 'integer', 'min-monochrome' => 'integer', 'max-monochrome' => 'integer',
+ 'resolution' => 'resolution', 'min-resolution' => 'resolution', 'max-resolution' => 'resolution',
+ 'scan' => array('progressive', 'interlace'),
+ 'grid' => 'integer',
+ );
+ if ($media_query) {
+ // Strip new lines and trim.
+ $media_query = str_replace(array("\r", "\n"), ' ', trim($media_query));
+
+ // Remove comments /* ... */.
+ $media_query = preg_replace('/\/\*[\s\S]*?\*\//', '', $media_query);
+
+ // Check mediaQuery_list: S* [mediaQuery [ ',' S* mediaQuery ]* ]?
+ $parts = explode(',', $media_query);
+ foreach ($parts as $part) {
+ // Split on ' and '
+ $query_parts = explode(' and ', trim($part));
+ $media_type_found = FALSE;
+ foreach ($query_parts as $query_part) {
+ $matches = array();
+ // Check expression: '(' S* media_feature S* [ ':' S* expr ]? ')' S*
+ if (preg_match('/^\(([\w\-]+)(:\s?([\w\-\.]+))?\)/', trim($query_part), $matches)) {
+ // Single expression.
+ if (isset($matches[1]) && !isset($matches[2])) {
+ if (!array_key_exists($matches[1], $media_features)) {
+ throw new InvalidBreakpointMediaQueryException('Invalid media feature detected.');
+ }
+ }
+ // Full expression.
+ elseif (isset($matches[3]) && !isset($matches[4])) {
+ $value = trim($matches[3]);
+ if (!array_key_exists($matches[1], $media_features)) {
+ // We need to allow vendor prefixed media fetures and make sure we
+ // are future proof, so only check allowed characters.
+ if (!preg_match('/^[a-zA-Z0-9\:\-\\ ]+$/i', trim($matches[1]))) {
+ throw new InvalidBreakpointMediaQueryException('Invalid media query detected.');
+ }
+ }
+ elseif (is_array($media_features[$matches[1]])) {
+ // Check if value is allowed.
+ if (!array_key_exists($value, $media_features[$matches[1]])) {
+ throw new InvalidBreakpointMediaQueryException('Value is not allowed.');
+ }
+ }
+ elseif (isset ($media_features[$matches[1]])) {
+ switch ($media_features[$matches[1]]) {
+ case 'length':
+ $length_matches = array();
+ if (preg_match('/^(\-)?(\d+(?:\.\d+)?)?((?:|em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|dpi|dpcm))$/i', trim($value), $length_matches)) {
+ // Only -0 is allowed.
+ if ($length_matches[1] === '-' && $length_matches[2] !== '0') {
+ throw new InvalidBreakpointMediaQueryException('Invalid length detected.');
+ }
+ // If there's a unit, a number is needed as well.
+ if ($length_matches[2] === '' && $length_matches[3] !== '') {
+ throw new InvalidBreakpointMediaQueryException('Unit found, value is missing.');
+ }
+ }
+ else {
+ throw new InvalidBreakpointMediaQueryException('Invalid unit detected.');
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ // Check [ONLY | NOT]? S* media_type
+ elseif (preg_match('/^((?:only|not)?\s?)([\w\-]+)$/i', trim($query_part), $matches)) {
+ if ($media_type_found) {
+ throw new InvalidBreakpointMediaQueryException('Only one media type is allowed.');
+ }
+ $media_type_found = TRUE;
+ }
+ // Check illegal [ONLY | NOT]? S* media_type
+ elseif (preg_match('/^((?:only|not)\s?)\(([\w\-]+)\)$/i', trim($query_part), $matches)) {
+ throw new InvalidBreakpointMediaQueryException('Invalid media query detected.');
+ }
+ else {
+ // We need to allow vendor prefixed media fetures and make sure we
+ // are future proof, so only check allowed characters.
+ if (!preg_match('/^[a-zA-Z0-9\-\\ ]+$/i', trim($query_part), $matches)) {
+ throw new InvalidBreakpointMediaQueryException('Invalid media query detected.');
+ }
+ }
+ }
+ }
+ return TRUE;
+ }
+ throw new InvalidBreakpointMediaQueryException('Media query is empty.');
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/BreakpointGroup.php b/core/modules/breakpoint/lib/Drupal/breakpoint/BreakpointGroup.php
new file mode 100644
index 0000000..de56397
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/BreakpointGroup.php
@@ -0,0 +1,214 @@
+loadAllBreakpoints();
+ }
+
+ /**
+ * Overrides Drupal\Core\Entity\Entity::save().
+ */
+ public function save() {
+ // Check if everything is valid.
+ if (!$this->isValid()) {
+ throw new InvalidBreakpointException('Invalid data detected.');
+ }
+ if (empty($this->id)) {
+ $this->id = $this->sourceType . '.' . $this->source . '.' . $this->name;
+ }
+ // Only save the keys, but return the full objects.
+ $this->breakpoints = array_keys($this->breakpoints);
+ parent::save();
+ $this->loadAllBreakpoints();
+ }
+
+ /**
+ * Checks if the breakpoint group is valid.
+ *
+ * @throws Drupal\breakpoint\InvalidBreakpointSourceTypeException
+ * @throws Drupal\breakpoint\InvalidBreakpointSourceException
+ *
+ * @return true
+ * Returns true if the breakpoint group is valid.
+ */
+ public function isValid() {
+ // Check for illegal values in breakpoint group source type.
+ if (!in_array($this->sourceType, array(
+ Breakpoint::SOURCE_TYPE_CUSTOM,
+ Breakpoint::SOURCE_TYPE_MODULE,
+ Breakpoint::SOURCE_TYPE_THEME)
+ )) {
+ throw new InvalidBreakpointSourceTypeException(format_string('Invalid source type @source_type', array(
+ '@source_type' => $this->sourceType,
+ )));
+ }
+ // Check for illegal characters in breakpoint group source.
+ if (preg_match('/[^a-z_]+/', $this->source) || empty($this->source)) {
+ throw new InvalidBreakpointSourceException(format_string("Invalid value '@source' for breakpoint group source property. Breakpoint group source property can only contain lowercase letters and underscores.", array('@source' => $this->source)));
+ }
+ // Check for illegal characters in breakpoint group name.
+ if (preg_match('/[^a-z0-9_]+/', $this->name || empty($this->name))) {
+ throw new InvalidBreakpointNameException(format_string("Invalid value '@name' for breakpoint group name property. Breakpoint group name property can only contain lowercase letters, numbers and underscores.", array('@name' => $this->name)));
+ }
+ return TRUE;
+ }
+
+ /**
+ * Duplicates a breakpoint group.
+ *
+ * The new breakpoint group inherits the breakpoints.
+ *
+ * @return Drupal\breakpoint\BreakpointGroup
+ */
+ public function duplicate() {
+ return entity_create('breakpoint_group', array(
+ 'breakpoints' => array_keys($this->breakpoints),
+ 'name' => 'clone_of_' . $this->name,
+ ));
+ }
+
+ /**
+ * Adds a breakpoint using a name and a media query.
+ *
+ * @param string $name
+ * The name of the breakpoint.
+ * @param string $media_query
+ * Media query.
+ */
+ public function addBreakpointFromMediaQuery($name, $media_query) {
+ // Use the existing breakpoint if it exists.
+ $breakpoint = entity_load('breakpoint', $this->sourceType . '.' . $this->name . '.' . $name);
+ if (!$breakpoint) {
+ // Build a new breakpoint.
+ $breakpoint = entity_create('breakpoint', array(
+ 'name' => $name,
+ 'label' => $name,
+ 'mediaQuery' => $media_query,
+ 'source' => $this->name,
+ 'sourceType' => $this->sourceType,
+ 'weight' => count($this->breakpoints),
+ ));
+ $breakpoint->save();
+ }
+ $this->breakpoints[$breakpoint->id()] = $breakpoint;
+ }
+
+ /**
+ * Adds one or more breakpoints to this group.
+ *
+ * The breakpoint name is either the machine_name or the id of a breakpoint.
+ *
+ * @param type $breakpoints
+ * Array containing breakpoints keyed by their id.
+ */
+ public function addBreakpoints($breakpoints) {
+ foreach ($breakpoints as $breakpoint_name) {
+ // Check if breakpoint exists, assume $breakpoint_name is a machine name.
+ $breakpoint = entity_load('breakpoint', $this->sourceType . '.' . $this->source . '.' . $breakpoint_name);
+ // If the breakpoint doesn't exist, assume $breakpoint_name is an id.
+ if (!$breakpoint) {
+ $breakpoint = entity_load('breakpoint', $breakpoint_name);
+ }
+ // If the breakpoint doesn't exists, do not add it.
+ if ($breakpoint) {
+ // Add breakpoint to group.
+ $this->breakpoints[$breakpoint->id()] = $breakpoint;
+ }
+ }
+ }
+
+ /**
+ * Loads all breakpoints, remove non-existing ones.
+ *
+ * @return array
+ * Array containing breakpoints keyed by their id.
+ */
+ protected function loadAllBreakpoints() {
+ $breakpoints = $this->breakpoints;
+ $this->breakpoints = array();
+ foreach ($breakpoints as $breakpoint_id) {
+ $breakpoint = breakpoint_load($breakpoint_id);
+ if ($breakpoint) {
+ $this->breakpoints[$breakpoint_id] = $breakpoint;
+ }
+ }
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/InvalidBreakpointException.php b/core/modules/breakpoint/lib/Drupal/breakpoint/InvalidBreakpointException.php
new file mode 100644
index 0000000..6889fa2
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/InvalidBreakpointException.php
@@ -0,0 +1,13 @@
+ 'Breakpoint general API functions',
+ 'description' => 'Test general API functions of the breakpoint module.',
+ 'group' => 'Breakpoint',
+ );
+ }
+
+ /**
+ * Test Breakpoint::buildConfigName().
+ */
+ public function testConfigName() {
+ // Try an invalid sourceType.
+ $breakpoint = entity_create('breakpoint', array(
+ 'label' => drupal_strtolower($this->randomName()),
+ 'source' => 'custom_module',
+ 'sourceType' => 'oops',
+ ));
+
+ $exception = FALSE;
+ try {
+ $breakpoint->save();
+ }
+ catch (InvalidBreakpointSourceTypeException $e) {
+ $exception = TRUE;
+ }
+ $this->assertTrue($exception, t('breakpoint_config_name: An exception is thrown when an invalid sourceType is entered.'));
+
+ // Try an invalid source.
+ $breakpoint->id = '';
+ $breakpoint->sourceType = Breakpoint::SOURCE_TYPE_CUSTOM;
+ $breakpoint->source = 'custom*_module source';
+
+ $exception = FALSE;
+ try {
+ $breakpoint->save();
+ }
+ catch (InvalidBreakpointSourceException $e) {
+ $exception = TRUE;
+ }
+ $this->assertTrue($exception, t('breakpoint_config_name: An exception is thrown when an invalid source is entered.'));
+
+ // Try an invalid name (make sure there is at least once capital letter).
+ $breakpoint->id = '';
+ $breakpoint->source = 'custom_module';
+ $breakpoint->name = drupal_ucfirst($this->randomName());
+
+ $exception = FALSE;
+ try {
+ $breakpoint->save();
+ }
+ catch (InvalidBreakpointNameException $e) {
+ $exception = TRUE;
+ }
+ $this->assertTrue($exception, t('breakpoint_config_name: An exception is thrown when an invalid name is entered.'));
+
+ // Try a valid breakpoint.
+ $breakpoint->id = '';
+ $breakpoint->name = drupal_strtolower($this->randomName());
+ $breakpoint->mediaQuery = 'all';
+
+ $exception = FALSE;
+ try {
+ $breakpoint->save();
+ }
+ catch (\Exception $e) {
+ $exception = TRUE;
+ }
+ $this->assertFalse($exception, t('breakpoint_config_name: No exception is thrown when a valid breakpoint is passed.'));
+ $this->assertEqual($breakpoint->id(), Breakpoint::SOURCE_TYPE_CUSTOM . '.custom_module.' . $breakpoint->name, t('breakpoint_config_name: A id is set when a valid breakpoint is passed.'));
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointCrudTest.php b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointCrudTest.php
new file mode 100644
index 0000000..38bc183
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointCrudTest.php
@@ -0,0 +1,57 @@
+ 'Breakpoint CRUD operations',
+ 'description' => 'Test creation, loading, updating, deleting of breakpoints.',
+ 'group' => 'Breakpoint',
+ );
+ }
+
+ /**
+ * Test CRUD operations for breakpoints.
+ */
+ public function testBreakpointCrud() {
+ // Add a breakpoint with minimum data only.
+ $breakpoint = entity_create('breakpoint', array(
+ 'label' => drupal_strtolower($this->randomName()),
+ 'mediaQuery' => '(min-width: 600px)',
+ ));
+ $breakpoint->save();
+
+ $this->verifyBreakpoint($breakpoint);
+
+ // Test breakpoint_load_all
+ $all_breakpoints = entity_load_multiple('breakpoint');
+ $config_name = $breakpoint->id();
+ $this->assertTrue(isset($all_breakpoints[$config_name]), t('breakpoint_load_all: New breakpoint is present when loading all breakpoints.'));
+ $this->verifyBreakpoint($breakpoint, $all_breakpoints[$config_name]);
+
+ // Update the breakpoint.
+ $breakpoint->weight = 1;
+ $breakpoint->multipliers['2x'] = '2x';
+ $breakpoint->save();
+ $this->verifyBreakpoint($breakpoint);
+
+ // Delete the breakpoint.
+ $breakpoint->delete();
+ $this->assertFalse(breakpoint_load($config_name), t('breakpoint_load: Loading a deleted breakpoint returns false.'), t('Breakpoints API'));
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupAPITest.php b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupAPITest.php
new file mode 100644
index 0000000..6639bda
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupAPITest.php
@@ -0,0 +1,81 @@
+ 'Breakpoint group general API functions',
+ 'description' => 'Test general API functions of the breakpoint module.',
+ 'group' => 'Breakpoint',
+ );
+ }
+
+ /**
+ * Test Breakpoint::buildConfigName().
+ */
+ public function testConfigName() {
+ // Try an invalid sourceType.
+ $label = $this->randomName();
+ $breakpoint_group = entity_create('breakpoint_group', array(
+ 'label' => $label,
+ 'name' => drupal_strtolower($label),
+ 'source' => 'custom_module',
+ 'sourceType' => 'oops',
+ ));
+
+ $exception = FALSE;
+ try {
+ $breakpoint_group->save();
+ }
+ catch (InvalidBreakpointSourceTypeException $e) {
+ $exception = TRUE;
+ }
+ $this->assertTrue($exception, t('An exception is thrown when an invalid sourceType is entered.'));
+
+ // Try an invalid source.
+ $breakpoint_group->name = '';
+ $breakpoint_group->sourceType = Breakpoint::SOURCE_TYPE_CUSTOM;
+ $breakpoint_group->source = 'custom*_module source';
+
+ $exception = FALSE;
+ try {
+ $breakpoint_group->save();
+ }
+ catch (InvalidBreakpointSourceException $e) {
+ $exception = TRUE;
+ }
+ $this->assertTrue($exception, t('An exception is thrown when an invalid source is entered.'));
+
+ // Try a valid breakpoint_group.
+ $breakpoint_group->name = 'test';
+ $breakpoint_group->source = 'custom_module_source';
+
+ $exception = FALSE;
+ try {
+ $breakpoint_group->save();
+ }
+ catch (\Exception $e) {
+ $exception = TRUE;
+ }
+ $this->assertFalse($exception, t('No exception is thrown when a valid data is passed.'));
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupCrudTest.php b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupCrudTest.php
new file mode 100644
index 0000000..3d8fd9b
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupCrudTest.php
@@ -0,0 +1,72 @@
+ 'Breakpoint group CRUD operations',
+ 'description' => 'Test creation, loading, updating, deleting of breakpoint groups.',
+ 'group' => 'Breakpoint',
+ );
+ }
+
+ /**
+ * Test CRUD operations for breakpoint groups.
+ */
+ public function testBreakpointGroupCrud() {
+ // Add breakpoints.
+ $breakpoints = array();
+ for ($i = 0; $i <= 3; $i++) {
+ $width = ($i + 1) * 200;
+ $breakpoint = entity_create('breakpoint', array(
+ 'name' => drupal_strtolower($this->randomName()),
+ 'weight' => $i,
+ 'mediaQuery' => "(min-width: {$width}px)",
+ ));
+ $breakpoint->save();
+ $breakpoints[$breakpoint->id()] = $breakpoint;
+ }
+ // Add a breakpoint group with minimum data only.
+ $label = $this->randomName();
+
+ $group = entity_create('breakpoint_group', array(
+ 'label' => $label,
+ 'name' => drupal_strtolower($label),
+ ));
+ $group->save();
+ $this->verifyBreakpointGroup($group);
+
+ // Update the breakpoint group.
+ $group->breakpoints = array_keys($breakpoints);
+ $group->save();
+ $this->verifyBreakpointGroup($group);
+
+ // Duplicate the breakpoint group.
+ $new_set = entity_create('breakpoint_group', array(
+ 'breakpoints' => $group->breakpoints,
+ 'name' => 'clone_of_' . $group->name,
+ ));
+ $duplicated_set = $group->duplicate();
+ $this->verifyBreakpointGroup($duplicated_set, $new_set);
+
+ // Delete the breakpoint group.
+ $group->delete();
+ $this->assertFalse(entity_load('breakpoint_group', $group->id()), t('breakpoint_group_load: Loading a deleted breakpoint group returns false.'), t('Breakpoints API'));
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupTestBase.php b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupTestBase.php
new file mode 100644
index 0000000..24a50ab
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointGroupTestBase.php
@@ -0,0 +1,66 @@
+id()) : $compare_set;
+
+ foreach ($properties as $property) {
+ $t_args = array(
+ '%group' => $group->label(),
+ '%property' => $property,
+ );
+ if (is_array($compare_set->{$property})) {
+ $this->assertEqual(array_keys($compare_set->{$property}), array_keys($group->{$property}), t('breakpoint_group_load: Proper %property for breakpoint group %group.', $t_args), $assert_set);
+ }
+ else {
+ $t_args = array(
+ '%group' => $group->label(),
+ '%property' => $property,
+ '%property1' => $compare_set->{$property},
+ '%property2' => $group->{$property},
+ );
+ $this->assertEqual($compare_set->{$property}, $group->{$property}, t('breakpoint_group_load: Proper %property: %property1 == %property2 for breakpoint group %group.', $t_args), $assert_set);
+ }
+ }
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointMediaQueryTest.php b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointMediaQueryTest.php
new file mode 100644
index 0000000..f7f4b02
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointMediaQueryTest.php
@@ -0,0 +1,125 @@
+ 'Breakpoint media query tests',
+ 'description' => 'Test validation of media queries.',
+ 'group' => 'Breakpoint',
+ );
+ }
+
+ /**
+ * Test valid media queries.
+ */
+ public function testValidMediaQueries() {
+ $media_queries = array(
+ // Bartik breakpoints.
+ '(min-width: 0px)',
+ 'all and (min-width: 560px) and (max-width:850px)',
+ 'all and (min-width: 851px)',
+ // Seven breakpoints.
+ '(min-width: 0em)',
+ 'screen and (min-width: 40em)',
+ // Stark breakpoints.
+ '(min-width: 0px)',
+ 'all and (min-width: 480px) and (max-width: 959px)',
+ 'all and (min-width: 960px)',
+ '(orientation)',
+ 'all and (orientation)',
+ 'not all and (orientation)',
+ 'only all and (orientation)',
+ 'screen and (width)',
+ 'screen and (width: 0)',
+ 'screen and (width: 0px)',
+ 'screen and (width: 0em)',
+ 'screen and (min-width: -0)',
+ 'screen and (max-width: 0)',
+ 'screen and (max-width: 0.3)',
+ 'screen and (min-width)',
+ // Multiline and comments.
+ 'screen and /* this is a comment */ (min-width)',
+ "screen\nand /* this is a comment */ (min-width)",
+ "screen\n\nand /* this is\n a comment */ (min-width)",
+ // Unrecognized features are allowed.
+ 'screen and (-webkit-min-device-pixel-ratio: 7)',
+ 'screen and (min-orientation: landscape)',
+ 'screen and (max-orientation: landscape)',
+ );
+
+ foreach ($media_queries as $media_query) {
+ $this->assertTrue(Breakpoint::isValidMediaQuery($media_query), $media_query . ' is valid.');
+ }
+ }
+
+ /**
+ * Test invalid media queries.
+ */
+ public function testInvalidMediaQueries() {
+ $media_queries = array(
+ '',
+ 'not (orientation)',
+ 'only (orientation)',
+ 'all and not all',
+ 'screen and (width: 0xx)',
+ 'screen and (width: -8xx)',
+ 'screen and (width: -xx)',
+ 'screen and (width: xx)',
+ 'screen and (width: px)',
+ 'screen and (width: -8px)',
+ 'screen and (width: -0.8px)',
+ 'screen and (height: 0xx)',
+ 'screen and (height: -8xx)',
+ 'screen and (height: -xx)',
+ 'screen and (height: xx)',
+ 'screen and (height: px)',
+ 'screen and (height: -8px)',
+ 'screen and (height: -0.8px)',
+ 'screen and (device-width: 0xx)',
+ 'screen and (device-width: -8xx)',
+ 'screen and (device-width: -xx)',
+ 'screen and (device-width: xx)',
+ 'screen and (device-width: px)',
+ 'screen and (device-width: -8px)',
+ 'screen and (device-width: -0.8px)',
+ 'screen and (device-height: 0xx)',
+ 'screen and (device-height: -8xx)',
+ 'screen and (device-height: -xx)',
+ 'screen and (device-height: xx)',
+ 'screen and (device-height: px)',
+ 'screen and (device-height: -8px)',
+ 'screen and (device-height: -0.8px)',
+ 'screen and (min-orientation)',
+ 'screen and (max-orientation)',
+ 'screen and (orientation: bogus)',
+ '(orientation: bogus)',
+ 'screen and (ori"entation: bogus)',
+ );
+
+ foreach ($media_queries as $media_query) {
+ try {
+ $this->assertFalse(Breakpoint::isValidMediaQuery($media_query), $media_query . ' is not valid.');
+ }
+ catch (InvalidBreakpointMediaQueryException $e) {
+ $this->assertTrue(TRUE, $media_query . ' is not valid.');
+ }
+ }
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointTestBase.php b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointTestBase.php
new file mode 100644
index 0000000..27b73c7
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointTestBase.php
@@ -0,0 +1,55 @@
+id()) : $compare_breakpoint;
+ foreach ($properties as $property) {
+ $t_args = array(
+ '%breakpoint' => $breakpoint->label(),
+ '%property' => $property,
+ );
+ $this->assertEqual($compare_breakpoint->{$property}, $breakpoint->{$property}, t('breakpoint_load: Proper %property for breakpoint %breakpoint.', $t_args), $assert_group);
+ }
+ }
+}
diff --git a/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointThemeTest.php b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointThemeTest.php
new file mode 100644
index 0000000..7072e02
--- /dev/null
+++ b/core/modules/breakpoint/lib/Drupal/breakpoint/Tests/BreakpointThemeTest.php
@@ -0,0 +1,136 @@
+ 'Breakpoint theme functionality',
+ 'description' => 'Thoroughly test the breakpoints provided by a theme.',
+ 'group' => 'Breakpoint',
+ );
+ }
+
+ /**
+ * Drupal\simpletest\WebTestBase\setUp().
+ */
+ public function setUp() {
+ parent::setUp();
+ theme_enable(array('breakpoint_test_theme'));
+ }
+
+ /**
+ * Test the breakpoints provided by a theme.
+ */
+ public function testThemeBreakpoints() {
+ // Verify the breakpoint group for breakpoint_test_theme was created.
+ $breakpoint_group_obj = entity_create('breakpoint_group', array(
+ 'label' => 'Breakpoint test theme',
+ 'name' => 'breakpoint_test_theme',
+ 'source' => 'breakpoint_test_theme',
+ 'sourceType' => Breakpoint::SOURCE_TYPE_THEME,
+ 'id' => Breakpoint::SOURCE_TYPE_THEME . '.breakpoint_test_theme.breakpoint_test_theme',
+ ));
+ $breakpoint_group_obj->breakpoints = array(
+ 'theme.breakpoint_test_theme.mobile' => array(),
+ 'theme.breakpoint_test_theme.narrow' => array(),
+ 'theme.breakpoint_test_theme.wide' => array(),
+ 'theme.breakpoint_test_theme.tv' => array(),
+ );
+
+ // Verify we can load this breakpoint defined by the theme.
+ $this->verifyBreakpointGroup($breakpoint_group_obj);
+
+ // Disable the test theme and verify the breakpoint group is deleted.
+ theme_disable(array('breakpoint_test_theme'));
+ $this->assertFalse(entity_load('breakpoint_group', $breakpoint_group_obj->id()), t('breakpoint_group_load: Loading a deleted breakpoint group returns false.'), t('Breakpoints API'));
+ }
+
+ /**
+ * Test the breakpoints defined by the custom group.
+ */
+ public function testThemeBreakpointGroup() {
+ // Verify the breakpoint group 'test' was created by breakpoint_test_theme.
+ $breakpoint_group_obj = entity_create('breakpoint_group', array(
+ 'label' => 'Test',
+ 'name' => 'test',
+ 'sourceType' => Breakpoint::SOURCE_TYPE_THEME,
+ 'source' => 'breakpoint_test_theme',
+ 'id' => Breakpoint::SOURCE_TYPE_THEME . '.breakpoint_test_theme.test',
+ ));
+ $breakpoint_group_obj->breakpoints = array(
+ 'theme.breakpoint_test_theme.mobile' => array('1.5x', '2.x'),
+ 'theme.breakpoint_test_theme.narrow' => array(),
+ 'theme.breakpoint_test_theme.wide' => array(),
+ );
+
+ // Verify we can load this breakpoint defined by the theme.
+ $this->verifyBreakpointGroup($breakpoint_group_obj);
+
+ // Disable the test theme and verify the breakpoint group is deleted.
+ theme_disable(array('breakpoint_test_theme'));
+ $this->assertFalse(entity_load('breakpoint_group', $breakpoint_group_obj->id()), t('breakpoint_group_load: Loading a deleted breakpoint group returns false.'), t('Breakpoints API'));
+ }
+
+ /**
+ * Test the breakpoints defined by the custom group in the module.
+ */
+ public function testThemeBreakpointGroupModule() {
+ // Call the import manually, since the testbot needs to enable the module
+ // first, otherwise the theme isn't detected.
+ _breakpoint_import_breakpoint_groups('breakpoint_theme_test', Breakpoint::SOURCE_TYPE_MODULE);
+
+ // Verify the breakpoint group 'module_test' was created by
+ // breakpoint_theme_test module.
+ $breakpoint_group_obj = entity_create('breakpoint_group', array(
+ 'label' => 'Test Module',
+ 'name' => 'module_test',
+ 'sourceType' => Breakpoint::SOURCE_TYPE_MODULE,
+ 'source' => 'breakpoint_theme_test',
+ 'id' => Breakpoint::SOURCE_TYPE_MODULE . '.breakpoint_theme_test.module_test',
+ ));
+ $breakpoint_group_obj->breakpoints = array(
+ 'theme.breakpoint_test_theme.mobile' => array(),
+ 'theme.breakpoint_test_theme.narrow' => array(),
+ 'theme.breakpoint_test_theme.wide' => array(),
+ );
+
+ // Verify we can load this breakpoint defined by the theme.
+ $this->verifyBreakpointGroup($breakpoint_group_obj);
+
+ // Disable the test theme and verify the breakpoint group still exists.
+ theme_disable(array('breakpoint_test_theme'));
+ $this->assertTrue(entity_load('breakpoint_group', $breakpoint_group_obj->id()), 'Breakpoint group still exists if theme is disabled.');
+
+ // Disable the test module and verify the breakpoint group still exists.
+ module_disable(array('breakpoint_theme_test'));
+ $this->assertTrue(entity_load('breakpoint_group', $breakpoint_group_obj->id()), 'Breakpoint group still exists if module is disabled.');
+
+ // Uninstall the test module and verify the breakpoint group is deleted.
+ module_uninstall(array('breakpoint_theme_test'));
+ $this->assertFalse(entity_load('breakpoint_group', $breakpoint_group_obj->id()), 'Breakpoint group is removed if module is uninstalled.');
+ }
+
+}
diff --git a/core/modules/breakpoint/tests/breakpoint_theme_test.info b/core/modules/breakpoint/tests/breakpoint_theme_test.info
new file mode 100644
index 0000000..d2896f6
--- /dev/null
+++ b/core/modules/breakpoint/tests/breakpoint_theme_test.info
@@ -0,0 +1,6 @@
+name = Breakpoint theme test
+description = Test breakpoints provided by themes
+package = Other
+core = 8.x
+hidden = TRUE
+dependencies[] = breakpoint
diff --git a/core/modules/breakpoint/tests/breakpoint_theme_test.module b/core/modules/breakpoint/tests/breakpoint_theme_test.module
new file mode 100644
index 0000000..50b5ff0
--- /dev/null
+++ b/core/modules/breakpoint/tests/breakpoint_theme_test.module
@@ -0,0 +1,13 @@
+loadBreakpointGroup();
+ $this->loadAllMappings();
+ }
+
+ /**
+ * Overrides Drupal\Core\Entity::save().
+ */
+ public function save() {
+ // Only save the keys, but return the full objects.
+ if (isset($this->breakpointGroup) && is_object($this->breakpointGroup)) {
+ $this->breakpointGroup = $this->breakpointGroup->id();
+ }
+ parent::save();
+ $this->loadBreakpointGroup();
+ $this->loadAllMappings();
+ }
+
+ /**
+ * Implements EntityInterface::createDuplicate().
+ */
+ public function createDuplicate() {
+ $duplicate = new PictureMapping();
+ $duplicate->id = '';
+ $duplicate->label = t('Clone of') . ' ' . $this->label();
+ $duplicate->mappings = $this->mappings;
+ return $duplicate;
+ }
+
+ /**
+ * Load breakpointGroup.
+ */
+ protected function loadBreakpointGroup() {
+ if ($this->breakpointGroup) {
+ $breakpoint_group = entity_load('breakpoint_group', $this->breakpointGroup);
+ $this->breakpointGroup = $breakpoint_group;
+ }
+ }
+
+ /**
+ * Load all mappings, remove non-existing ones.
+ */
+ protected function loadAllMappings() {
+ $loaded_mappings = $this->mappings;
+ $this->mappings = array();
+ if ($this->breakpointGroup) {
+ foreach ($this->breakpointGroup->breakpoints as $breakpoint_id => $breakpoint) {
+ // Get the mapping for the default multiplier.
+ $this->mappings[$breakpoint_id]['1x'] = '';
+ if (isset($loaded_mappings[$breakpoint_id]['1x'])) {
+ $this->mappings[$breakpoint_id]['1x'] = $loaded_mappings[$breakpoint_id]['1x'];
+ }
+
+ // Get the mapping for the other multipliers.
+ if (isset($breakpoint->multipliers) && !empty($breakpoint->multipliers)) {
+ foreach ($breakpoint->multipliers as $multiplier => $status) {
+ if ($status) {
+ $this->mappings[$breakpoint_id][$multiplier] = '';
+ if (isset($loaded_mappings[$breakpoint_id][$multiplier])) {
+ $this->mappings[$breakpoint_id][$multiplier] = $loaded_mappings[$breakpoint_id][$multiplier];
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/core/modules/picture/lib/Drupal/picture/PictureMappingFormController.php b/core/modules/picture/lib/Drupal/picture/PictureMappingFormController.php
new file mode 100644
index 0000000..f5aa3d3
--- /dev/null
+++ b/core/modules/picture/lib/Drupal/picture/PictureMappingFormController.php
@@ -0,0 +1,119 @@
+operation == 'duplicate') {
+ $picture_mapping = $picture_mapping->createDuplicate();
+ $this->setEntity($picture_mapping, $form_state);
+ }
+ $form['label'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Label'),
+ '#maxlength' => 255,
+ '#default_value' => $picture_mapping->label(),
+ '#description' => t("Example: 'Main content' or 'Sidebar'."),
+ '#required' => TRUE,
+ );
+ $form['id'] = array(
+ '#type' => 'machine_name',
+ '#default_value' => $picture_mapping->id(),
+ '#machine_name' => array(
+ 'exists' => 'picture_mapping_load',
+ 'source' => array('label'),
+ ),
+ '#disabled' => (bool) $picture_mapping->id() && $this->operation != 'duplicate',
+ );
+ $form['breakpointGroup'] = array(
+ '#type' => 'select',
+ '#title' => t('Breakpoint Group'),
+ '#default_value' => !empty($picture_mapping->breakpointGroup) ? $picture_mapping->breakpointGroup->id() : '',
+ '#options' => breakpoint_group_labels(),
+ '#required' => TRUE,
+ );
+
+ $image_styles = image_style_options(TRUE);
+ foreach ($picture_mapping->mappings as $breakpoint_id => $mapping) {
+ foreach ($mapping as $multiplier => $image_style) {
+ $label = $multiplier . ' ' . $picture_mapping->breakpointGroup->breakpoints[$breakpoint_id]->name . ' [' . $picture_mapping->breakpointGroup->breakpoints[$breakpoint_id]->mediaQuery . ']';
+ $form['mappings'][$breakpoint_id][$multiplier] = array(
+ '#type' => 'select',
+ '#title' => check_plain($label),
+ '#options' => $image_styles,
+ '#default_value' => $image_style,
+ );
+ }
+ }
+
+ $form['#tree'] = TRUE;
+
+ return parent::form($form, $form_state, $picture_mapping);
+ }
+
+ /**
+ * Overrides Drupal\Core\Entity\EntityFormController::actions().
+ */
+ protected function actions(array $form, array &$form_state) {
+ // Only includes a Save action for the entity, no direct Delete button.
+ return array(
+ 'submit' => array(
+ '#value' => t('Save'),
+ '#validate' => array(
+ array($this, 'validate'),
+ ),
+ '#submit' => array(
+ array($this, 'submit'),
+ array($this, 'save'),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Overrides Drupal\Core\Entity\EntityFormController::validate().
+ */
+ public function validate(array $form, array &$form_state) {
+ }
+
+ /**
+ * Overrides Drupal\Core\Entity\EntityFormController::save().
+ */
+ public function save(array $form, array &$form_state) {
+ $picture_mapping = $this->getEntity($form_state);
+ $picture_mapping->save();
+
+ watchdog('picture', 'Picture mapping @label saved.', array('@label' => $picture_mapping->label()), WATCHDOG_NOTICE);
+ drupal_set_message(t('Picture mapping %label saved.', array('%label' => $picture_mapping->label())));
+
+ $form_state['redirect'] = 'admin/config/media/picturemapping';
+ }
+
+}
diff --git a/core/modules/picture/lib/Drupal/picture/PictureMappingListController.php b/core/modules/picture/lib/Drupal/picture/PictureMappingListController.php
new file mode 100644
index 0000000..6329f92
--- /dev/null
+++ b/core/modules/picture/lib/Drupal/picture/PictureMappingListController.php
@@ -0,0 +1,35 @@
+entityInfo['list path'];
+ $items = parent::hookMenu();
+
+ // Override the access callback.
+ $items[$path]['title'] = 'Picture Mappings';
+ $items[$path]['description'] = 'Manage list of pictures.';
+ $items[$path]['access callback'] = 'user_access';
+ $items[$path]['access arguments'] = array('administer pictures');
+
+ return $items;
+ }
+
+}
diff --git a/core/modules/picture/lib/Drupal/picture/Plugin/field/formatter/PictureFormatter.php b/core/modules/picture/lib/Drupal/picture/Plugin/field/formatter/PictureFormatter.php
new file mode 100644
index 0000000..4a046d2
--- /dev/null
+++ b/core/modules/picture/lib/Drupal/picture/Plugin/field/formatter/PictureFormatter.php
@@ -0,0 +1,189 @@
+ $picture_mapping) {
+ $picture_options[$machine_name] = $picture_mapping->label();
+ }
+ }
+
+ $elements['picture_mapping'] = array(
+ '#title' => t('Picture mapping'),
+ '#type' => 'select',
+ '#default_value' => $this->getSetting('picture_mapping'),
+ '#required' => TRUE,
+ '#options' => $picture_options,
+ );
+
+ $image_styles = image_style_options(FALSE);
+ $elements['fallback_image_style'] = array(
+ '#title' => t('Fallback image style'),
+ '#type' => 'select',
+ '#default_value' => $this->getSetting('fallback_image_style'),
+ '#empty_option' => t('Automatic'),
+ '#options' => $image_styles,
+ );
+
+ $link_types = array(
+ 'content' => t('Content'),
+ 'file' => t('File'),
+ );
+ $elements['image_link'] = array(
+ '#title' => t('Link image to'),
+ '#type' => 'select',
+ '#default_value' => $this->getSetting('image_link'),
+ '#empty_option' => t('Nothing'),
+ '#options' => $link_types,
+ );
+
+ return $elements;
+ }
+
+ /**
+ * Implements Drupal\field\Plugin\Type\Formatter\FormatterInterface::settingsForm().
+ */
+ public function settingsSummary() {
+ $summary = array();
+
+ $picture_mapping = entity_load('picture_mapping', $this->getSetting('picture_mapping'));
+ if ($picture_mapping) {
+ $summary[] = t('Picture mapping: @picture_mapping', array('@picture_mapping' => $picture_mapping->label()));
+ }
+ else {
+ $summary[] = t("Picture mapping doesn't exists");
+ }
+
+ $image_styles = image_style_options(FALSE);
+ unset($image_styles['']);
+ if (isset($image_styles[$this->getSetting('fallback_image_style')])) {
+ $summary[] = t('Fallback Image style: @style', array('@style' => $image_styles[$this->getSetting('fallback_image_style')]));
+ }
+ else {
+ $summary[] = t('Automatic fallback');
+ }
+
+ $link_types = array(
+ 'content' => t('Linked to content'),
+ 'file' => t('Linked to file'),
+ );
+ // Display this setting only if image is linked.
+ if (isset($link_types[$this->getSetting('image_link')])) {
+ $summary[] = $link_types[$this->getSetting('image_link')];
+ }
+
+ return implode(' ', $summary);
+ }
+
+ /**
+ * Implements Drupal\field\Plugin\Type\Formatter\FormatterInterface::viewElements().
+ */
+ public function viewElements(EntityInterface $entity, $langcode, array $items) {
+ $elements = array();
+ // Check if the formatter involves a link.
+ if ($this->getSetting('image_link') == 'content') {
+ $uri = $entity->uri();
+ }
+ elseif ($this->getSetting('image_link') == 'file') {
+ $link_file = TRUE;
+ }
+
+ $breakpoint_styles = array();
+ $fallback_image_style = '';
+
+ $picture_mapping = entity_load('picture_mapping', $this->getSetting('picture_mapping'));
+ if ($picture_mapping) {
+ foreach ($picture_mapping->mappings as $breakpoint_name => $multipliers) {
+ // Make sure there are multipliers.
+ if (!empty($multipliers)) {
+ // Make sure that the breakpoint exists and is enabled.
+ // @todo add the following is breakpoint->status is added again:
+ // $picture_mapping->breakpointGroup->breakpoints[$breakpoint_name]->status
+ if (isset($picture_mapping->breakpointGroup->breakpoints[$breakpoint_name])) {
+ $breakpoint = $picture_mapping->breakpointGroup->breakpoints[$breakpoint_name];
+
+ // Determine the enabled multipliers.
+ $multipliers = array_intersect_key($multipliers, $breakpoint->multipliers);
+ foreach ($multipliers as $multiplier => $image_style) {
+ // Make sure the multiplier still exists.
+ //if (!empty(array_intersect($multiplier, $breakpoint->multipliers))) {
+ if (!empty($image_style)) {
+ // First mapping found is used as fallback.
+ if (empty($fallback_image_style)) {
+ $fallback_image_style = $image_style;
+ }
+ if (!isset($breakpoint_styles[$breakpoint_name])) {
+ $breakpoint_styles[$breakpoint_name] = array();
+ }
+ $breakpoint_styles[$breakpoint_name][$multiplier] = $image_style;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Check if the user defined a custom fallback image style.
+ if ($this->getSetting('fallback_image_style')) {
+ $fallback_image_style = $this->getSetting('fallback_image_style');
+ }
+
+ foreach ($items as $delta => $item) {
+ if (isset($link_file)) {
+ $uri = array(
+ 'path' => file_create_url($item['uri']),
+ 'options' => array(),
+ );
+ }
+ $elements[$delta] = array(
+ '#theme' => 'picture_formatter',
+ '#attached' => array('library' => array(
+ array('picture', 'matchmedia'),
+ array('picture', 'picturefill'),
+ )),
+ '#item' => $item,
+ '#image_style' => $fallback_image_style,
+ '#breakpoints' => $breakpoint_styles,
+ '#path' => isset($uri) ? $uri : '',
+ );
+ }
+
+ return $elements;
+ }
+}
+
diff --git a/core/modules/picture/lib/Drupal/picture/Tests/PictureAdminUITest.php b/core/modules/picture/lib/Drupal/picture/Tests/PictureAdminUITest.php
new file mode 100644
index 0000000..744cd77
--- /dev/null
+++ b/core/modules/picture/lib/Drupal/picture/Tests/PictureAdminUITest.php
@@ -0,0 +1,143 @@
+ 'Picture administration functionality',
+ 'description' => 'Thoroughly test the administrative interface of the picture module.',
+ 'group' => 'Picture',
+ );
+ }
+
+ /**
+ * Drupal\simpletest\WebTestBase\setUp().
+ */
+ public function setUp() {
+ parent::setUp();
+
+ // Create user.
+ $this->admin_user = $this->drupalCreateUser(array(
+ 'administer pictures',
+ ));
+
+ $this->drupalLogin($this->admin_user);
+
+ // Add breakpoint_group and breakpoints.
+ $breakpoint_group = entity_create('breakpoint_group', array(
+ 'id' => 'atestset',
+ 'label' => 'A test set',
+ 'sourceType' => Breakpoint::SOURCE_TYPE_CUSTOM,
+ ));
+
+ $breakpoints = array();
+ $breakpoint_names = array('small', 'medium', 'large');
+ for ($i = 0; $i < 3; $i++) {
+ $width = ($i + 1) * 200;
+ $breakpoint = entity_create('breakpoint', array(
+ 'name' => $breakpoint_names[$i],
+ 'mediaQuery' => "(min-width: {$width}px)",
+ 'source' => 'user',
+ 'sourceType' => 'custom',
+ 'multipliers' => array(
+ '1.5x' => 0,
+ '2x' => '2x',
+ ),
+ ));
+ $breakpoint->save();
+ $breakpoint_group->breakpoints[$breakpoint->id()] = $breakpoint;
+ }
+ $breakpoint_group->save();
+
+ }
+
+ /**
+ * Test picture administration functionality.
+ */
+ public function testPictureAdmin() {
+ // We start without any default mappings.
+ $this->drupalGet('admin/config/media/picturemapping');
+ $this->assertText('There is no Picture mapping yet.');
+
+ // Add a new picture mapping, our breakpoint set should be selected.
+ $this->drupalGet('admin/config/media/picturemapping/add');
+ $this->assertFieldByName('breakpointGroup', 'atestset');
+
+ // Create a new group.
+ $edit = array(
+ 'label' => 'Mapping One',
+ 'id' => 'mapping_one',
+ 'breakpointGroup' => 'atestset',
+ );
+ $this->drupalPost('admin/config/media/picturemapping/add', $edit, t('Save'));
+
+ // Check if the new group is created.
+ $this->assertResponse(200);
+ $this->drupalGet('admin/config/media/picturemapping');
+ $this->assertNoText('There is no Picture mapping yet.');
+ $this->assertText('Mapping One');
+ $this->assertText('mapping_one');
+
+ // Edit the group.
+ $this->drupalGet('admin/config/media/picturemapping/mapping_one/edit');
+ $this->assertFieldByName('label', 'Mapping One');
+ $this->assertFieldByName('breakpointGroup', 'atestset');
+
+ // Check if the dropdows are present for the mappings.
+ $this->assertFieldByName('mappings[custom.user.small][1x]', '');
+ $this->assertFieldByName('mappings[custom.user.small][2x]', '');
+ $this->assertFieldByName('mappings[custom.user.medium][1x]', '');
+ $this->assertFieldByName('mappings[custom.user.medium][2x]', '');
+ $this->assertFieldByName('mappings[custom.user.large][1x]', '');
+ $this->assertFieldByName('mappings[custom.user.large][2x]', '');
+
+ // Save mappings for 1x variant only.
+ $edit = array(
+ 'label' => 'Mapping One',
+ 'breakpointGroup' => 'atestset',
+ 'mappings[custom.user.small][1x]' => 'thumbnail',
+ 'mappings[custom.user.medium][1x]' => 'medium',
+ 'mappings[custom.user.large][1x]' => 'large',
+ );
+ $this->drupalPost('admin/config/media/picturemapping/mapping_one/edit', $edit, t('Save'));
+ $this->drupalGet('admin/config/media/picturemapping/mapping_one/edit');
+ $this->assertFieldByName('mappings[custom.user.small][1x]', 'thumbnail');
+ $this->assertFieldByName('mappings[custom.user.small][2x]', '');
+ $this->assertFieldByName('mappings[custom.user.medium][1x]', 'medium');
+ $this->assertFieldByName('mappings[custom.user.medium][2x]', '');
+ $this->assertFieldByName('mappings[custom.user.large][1x]', 'large');
+ $this->assertFieldByName('mappings[custom.user.large][2x]', '');
+
+ // Delete the mapping.
+ $this->drupalGet('admin/config/media/picturemapping/mapping_one/delete');
+ $this->drupalPost(NULL, array(), t('Delete'));
+ $this->drupalGet('admin/config/media/picturemapping');
+ $this->assertText('There is no Picture mapping yet.');
+ }
+
+}
diff --git a/core/modules/picture/lib/Drupal/picture/Tests/PictureFieldDisplayTest.php b/core/modules/picture/lib/Drupal/picture/Tests/PictureFieldDisplayTest.php
new file mode 100644
index 0000000..d0e19be
--- /dev/null
+++ b/core/modules/picture/lib/Drupal/picture/Tests/PictureFieldDisplayTest.php
@@ -0,0 +1,189 @@
+ 'Picture field display tests',
+ 'description' => 'Test picture display formatter.',
+ 'group' => 'Picture',
+ );
+ }
+
+ /**
+ * Drupal\simpletest\WebTestBase\setUp().
+ */
+ public function setUp() {
+ parent::setUp();
+
+ // Create user.
+ $this->admin_user = $this->drupalCreateUser(array('administer pictures', 'access content', 'access administration pages', 'administer site configuration', 'administer content types', 'administer nodes', 'create article content', 'edit any article content', 'delete any article content', 'administer image styles'));
+ $this->drupalLogin($this->admin_user);
+
+ // Add breakpoint_group and breakpoints.
+ $breakpoint_group = entity_create('breakpoint_group', array(
+ 'id' => 'atestset',
+ 'label' => 'A test set',
+ 'sourceType' => Breakpoint::SOURCE_TYPE_CUSTOM,
+ ));
+
+ $breakpoints = array();
+ $breakpoint_names = array('small', 'medium', 'large');
+ for ($i = 0; $i < 3; $i++) {
+ $width = ($i + 1) * 200;
+ $breakpoint = entity_create('breakpoint', array(
+ 'name' => $breakpoint_names[$i],
+ 'mediaQuery' => "(min-width: {$width}px)",
+ 'source' => 'user',
+ 'sourceType' => 'custom',
+ 'multipliers' => array(
+ '1.5x' => 0,
+ '2x' => '2x',
+ ),
+ ));
+ $breakpoint->save();
+ $breakpoint_group->breakpoints[$breakpoint->id()] = $breakpoint;
+ }
+ $breakpoint_group->save();
+
+ // Add picture mapping.
+ $picture_mapping = new PictureMapping();
+ $picture_mapping->id = 'mapping_one';
+ $picture_mapping->label = 'Mapping One';
+ $picture_mapping->breakpointGroup = 'atestset';
+ $picture_mapping->save();
+ $picture_mapping->mappings['custom.user.small']['1x'] = 'thumbnail';
+ $picture_mapping->mappings['custom.user.medium']['1x'] = 'medium';
+ $picture_mapping->mappings['custom.user.large']['1x'] = 'large';
+ $picture_mapping->save();
+ }
+
+ /**
+ * Test picture formatters on node display for public files.
+ */
+ public function testPictureFieldFormattersPublic() {
+ $this->_testPictureFieldFormatters('public');
+ }
+
+ /**
+ * Test picture formatters on node display for private files.
+ */
+ public function testPictureFieldFormattersPrivate() {
+ // Remove access content permission from anonymous users.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array('access content' => FALSE));
+ $this->_testPictureFieldFormatters('private');
+ }
+
+ /**
+ * Test picture formatters on node display.
+ */
+ public function _testPictureFieldFormatters($scheme) {
+ $field_name = drupal_strtolower($this->randomName());
+ $this->createImageField($field_name, 'article', array('uri_scheme' => $scheme));
+ // Create a new node with an image attached.
+ $test_image = current($this->drupalGetTestFiles('image'));
+ $nid = $this->uploadNodeImage($test_image, $field_name, 'article');
+ $node = node_load($nid, TRUE);
+
+ // Use the picture formatter.
+ $instance = field_info_instance('node', $field_name, 'article');
+ $instance['display']['default']['type'] = 'picture';
+ $instance['display']['default']['module'] = 'picture';
+
+ // Test that the default formatter is being used.
+ $image_uri = file_load($node->{$field_name}[LANGUAGE_NOT_SPECIFIED][0]['fid'])->uri;
+ $image_info = array(
+ 'uri' => $image_uri,
+ 'width' => 40,
+ 'height' => 20,
+ );
+ $default_output = theme('image', $image_info);
+ $this->assertRaw($default_output, 'Default formatter displaying correctly on full node view.');
+
+ // Use the picture formatter linked to file formatter.
+ $instance = field_info_instance('node', $field_name, 'article');
+ $instance['display']['default']['type'] = 'picture';
+ $instance['display']['default']['module'] = 'picture';
+ $instance['display']['default']['settings']['image_link'] = 'file';
+ field_update_instance($instance);
+ $default_output = l(theme('image', $image_info), file_create_url($image_uri), array('html' => TRUE));
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw($default_output, 'Image linked to file formatter displaying correctly on full node view.');
+ // Verify that the image can be downloaded.
+ $this->assertEqual(file_get_contents($test_image->uri), $this->drupalGet(file_create_url($image_uri)), 'File was downloaded successfully.');
+ if ($scheme == 'private') {
+ // Only verify HTTP headers when using private scheme and the headers are
+ // sent by Drupal.
+ $this->assertEqual($this->drupalGetHeader('Content-Type'), 'image/png', 'Content-Type header was sent.');
+ $this->assertEqual($this->drupalGetHeader('Content-Disposition'), 'inline; filename="' . $test_image->filename . '"', 'Content-Disposition header was sent.');
+ $this->assertTrue(strstr($this->drupalGetHeader('Cache-Control'), 'private') !== FALSE, 'Cache-Control header was sent.');
+
+ // Log out and try to access the file.
+ $this->drupalLogout();
+ $this->drupalGet(file_create_url($image_uri));
+ $this->assertResponse('403', 'Access denied to original image as anonymous user.');
+
+ // Log in again.
+ $this->drupalLogin($this->admin_user);
+ }
+
+ // Use the picture formatter with a picture mapping.
+ $instance['display']['default']['settings']['picture_mapping'] = 'mapping_one';
+ field_update_instance($instance);
+ // Output should contain all image styles and all breakpoints.
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw('/styles/thumbnail/');
+ $this->assertRaw('/styles/medium/');
+ $this->assertRaw('/styles/large/');
+ $this->assertRaw('media="(min-width: 200px)"');
+ $this->assertRaw('media="(min-width: 400px)"');
+ $this->assertRaw('media="(min-width: 600px)"');
+
+ // Test the fallback image style.
+ $instance['display']['default']['settings']['image_link'] = '';
+ $instance['display']['default']['settings']['fallback_image_style'] = 'large';
+ field_update_instance($instance);
+
+ $this->drupalGet(image_style_url('large', $image_uri));
+ $image_info['uri'] = $image_uri;
+ $image_info['width'] = 480;
+ $image_info['height'] = 240;
+ $image_info['style_name'] = 'large';
+ $default_output = '';
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw($default_output, 'Image style thumbnail formatter displaying correctly on full node view.');
+
+ if ($scheme == 'private') {
+ // Log out and try to access the file.
+ $this->drupalLogout();
+ $this->drupalGet(image_style_url('large', $image_uri));
+ $this->assertResponse('403', 'Access denied to image style thumbnail as anonymous user.');
+ }
+ }
+
+}
diff --git a/core/modules/picture/picture.info b/core/modules/picture/picture.info
new file mode 100644
index 0000000..0b7929b
--- /dev/null
+++ b/core/modules/picture/picture.info
@@ -0,0 +1,9 @@
+name = Picture
+description = Picture element
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = breakpoint
+dependencies[] = config
+dependencies[] = image
+configure = admin/config/media/picturemapping
\ No newline at end of file
diff --git a/core/modules/picture/picture.module b/core/modules/picture/picture.module
new file mode 100644
index 0000000..2811a2b
--- /dev/null
+++ b/core/modules/picture/picture.module
@@ -0,0 +1,334 @@
+ array(
+ 'title' => t('Administer Pictures'),
+ 'description' => t('Administer Pictures'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function picture_menu() {
+ $items = array();
+
+ $items['admin/config/media/picturemapping'] = array(
+ 'title' => 'Picture Mappings',
+ 'description' => 'Manage picture mappings',
+ 'access arguments' => array('administer pictures'),
+ 'weight' => 10,
+ 'page callback' => 'picture_mapping_page',
+ 'file' => 'picture_mapping.admin.inc',
+ );
+ $items['admin/config/media/picturemapping/add'] = array(
+ 'title' => 'Add picture mapping',
+ 'page callback' => 'picture_mapping_page_add',
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer pictures'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'picture_mapping.admin.inc',
+ );
+ $items['admin/config/media/picturemapping/%picture_mapping/edit'] = array(
+ 'title' => 'Edit picture mapping',
+ 'page callback' => 'picture_mapping_page_edit',
+ 'page arguments' => array(4),
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer pictures'),
+ 'file' => 'picture_mapping.admin.inc',
+ );
+ $items['admin/config/media/picturemapping/%picture_mapping/duplicate'] = array(
+ 'title' => 'Duplicate picture mapping',
+ 'page callback' => 'picture_mapping_page_duplicate',
+ 'page arguments' => array(4),
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer pictures'),
+ 'file' => 'picture_mapping.admin.inc',
+ );
+ $items['admin/config/media/picturemapping/%picture_mapping/delete'] = array(
+ 'title' => 'Delete',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('picture_mapping_action_confirm', 4, 5),
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer pictures'),
+ 'file' => 'picture_mapping.admin.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function picture_entity_info() {
+ $types['picture_mapping'] = array(
+ 'label' => 'Picture mapping',
+ 'entity class' => 'Drupal\picture\PictureMapping',
+ 'controller class' => 'Drupal\Core\Config\Entity\ConfigStorageController',
+ 'config prefix' => 'picture.mappings',
+ 'entity keys' => array(
+ 'id' => 'id',
+ 'label' => 'label',
+ 'uuid' => 'uuid',
+ ),
+ 'form controller class' => array(
+ 'default' => 'Drupal\picture\PictureMappingFormController',
+ 'add' => 'Drupal\picture\PictureMappingFormController',
+ 'duplicate' => 'Drupal\picture\PictureMappingFormController',
+ ),
+ 'list controller class' => 'Drupal\picture\PictureMappingListController',
+ 'list path' => 'admin/config/media/picturemapping',
+ 'uri callback' => 'picture_mapping_uri',
+ );
+
+ return $types;
+}
+
+/**
+ * Implements hook_library_info().
+ */
+function picture_library_info() {
+ $libraries['matchmedia'] = array(
+ 'title' => t('Matchmedia'),
+ 'website' => 'https://github.com/attiks/picturefill-proposal',
+ 'version' => '0.1',
+ 'js' => array(
+ drupal_get_path('module', 'picture') . '/picturefill/matchmedia.js' => array('type' => 'file', 'weight' => -10, 'group' => JS_DEFAULT),
+ ),
+ );
+ $libraries['picturefill'] = array(
+ 'title' => t('Picturefill'),
+ 'website' => 'https://github.com/attiks/picturefill-proposal',
+ 'version' => '0.1',
+ 'js' => array(
+ drupal_get_path('module', 'picture') . '/picturefill/picturefill.js' => array('type' => 'file', 'weight' => -10, 'group' => JS_DEFAULT),
+ ),
+ );
+ return $libraries;
+}
+
+/**
+ * Load one picture by its identifier.
+ *
+ * @param int $id
+ * The id of the picture mapping to load.
+ *
+ * @return Drupal\picture\Picture
+ * The entity object, or FALSE if there is no entity with the given id.
+ *
+ * @todo Needed for menu_callback
+ *
+ * @see http://drupal.org/node/1798214
+ *
+ */
+function picture_mapping_load($id) {
+ return entity_load('picture_mapping', $id);
+}
+
+/**
+ * Picture uri callback.
+ */
+function picture_mapping_uri(PictureMapping $picture_mapping) {
+ return array(
+ 'path' => 'admin/config/media/picturemapping/' . $picture_mapping->id(),
+ );
+}
+
+/**
+ * Picture uri callback.
+ */
+function picture_mapping_set_uri(PictureMapping $picture_mapping) {
+ return array(
+ 'path' => 'admin/config/media/picturemapping/' . $picture_mapping->id(),
+ );
+}
+
+/**
+ * Implements hook_theme().
+ */
+function picture_theme() {
+ return array(
+ 'picture' => array(
+ 'variables' => array(
+ 'style_name' => NULL,
+ 'path' => NULL,
+ 'width' => NULL,
+ 'height' => NULL,
+ 'alt' => '',
+ 'title' => NULL,
+ 'attributes' => array(),
+ 'breakpoints' => array(),
+ ),
+ ),
+ 'picture_formatter' => array(
+ 'variables' => array(
+ 'item' => NULL,
+ 'path' => NULL,
+ 'image_style' => NULL,
+ 'breakpoints' => array(),
+ ),
+ ),
+ 'picture_source' => array(
+ 'variables' => array(
+ 'src' => NULL,
+ 'srcset' => NULL,
+ 'dimension' => NULL,
+ 'media' => NULL,
+ ),
+ ),
+ );
+}
+
+function theme_picture_formatter($variables) {
+ if (!isset($variables['breakpoints']) || empty($variables['breakpoints'])) {
+ return theme('image_formatter', $variables);
+ }
+
+ $item = $variables['item'];
+
+ // Do not output an empty 'title' attribute.
+ if (isset($item['title']) && drupal_strlen($item['title']) == 0) {
+ unset($item['title']);
+ }
+
+ $item['style_name'] = $variables['image_style'];
+ $item['breakpoints'] = $variables['breakpoints'];
+
+ if (!isset($item['path']) && isset($variables['uri'])) {
+ $item['path'] = $variables['uri'];
+ }
+ $output = theme('picture', $item);
+
+ if (isset($variables['path']['path'])) {
+ $path = $variables['path']['path'];
+ $options = isset($variables['path']['options']) ? $variables['path']['options'] : array();
+ $options['html'] = TRUE;
+ $output = l($output, $path, $options);
+ }
+ return $output;
+}
+
+/**
+ * Theme a picture element.
+ */
+function theme_picture($variables) {
+ // Make sure that width and height are proper values
+ // If they exists we'll output them
+ // @see http://www.w3.org/community/respimg/2012/06/18/florians-compromise/
+ if (isset($variables['width']) && empty($variables['width'])) {
+ unset($variables['width']);
+ unset($variables['height']);
+ }
+ elseif (isset($variables['height']) && empty($variables['height'])) {
+ unset($variables['width']);
+ unset($variables['height']);
+ }
+
+ $sources = array();
+ $output = array();
+
+ // Fallback image, output as source with media query.
+ $sources[] = array(
+ 'src' => image_style_url($variables['style_name'], $variables['uri']),
+ 'dimensions' => picture_get_image_dimensions($variables),
+ );
+
+ // All breakpoints and multipliers.
+ foreach ($variables['breakpoints'] as $breakpoint_name => $multipliers) {
+ $breakpoint = breakpoint_load($breakpoint_name);
+ if ($breakpoint) {
+ $new_sources = array();
+ foreach ($multipliers as $multiplier => $image_style) {
+ $new_source = $variables;
+ $new_source['style_name'] = $image_style;
+ $new_source['#multiplier'] = $multiplier;
+ $new_sources[] = $new_source;
+ }
+
+ // Only one image, use src.
+ if (count($new_sources) == 1) {
+ $sources[] = array(
+ 'src' => image_style_url($new_sources[0]['style_name'], $new_sources[0]['uri']),
+ 'dimensions' => picture_get_image_dimensions($new_sources[0]),
+ 'media' => $breakpoint->mediaQuery,
+ );
+ }
+ else {
+ // Mutliple images, use srcset.
+ $srcset = array();
+ foreach ($new_sources as $new_source) {
+ $srcset[] = image_style_url($new_source['style_name'], $new_source['uri']) . ' ' . $new_source['#multiplier'];
+ }
+ $sources[] = array(
+ 'srcset' => implode(', ', $srcset),
+ 'dimensions' => picture_get_image_dimensions($new_sources[0]),
+ 'media' => $breakpoint->mediaQuery,
+ );
+ }
+ }
+ }
+
+ if (!empty($sources)) {
+ $attributes = array();
+ foreach (array('alt', 'title') as $key) {
+ if (isset($variables[$key])) {
+ $attributes[$key] = $variables[$key];
+ }
+ }
+ $output[] = '';
+
+ // add source tags to the output.
+ foreach ($sources as $source) {
+ $output[] = theme('picture_source', $source);
+ }
+
+ // output the fallback image.
+ $output[] = '';
+ $output[] = '';
+ return implode("\n", $output);
+ }
+}
+
+function theme_picture_source($variables) {
+ $output = array();
+ if (isset($variables['media']) && !empty($variables['media'])) {
+ if (!isset($variables['srcset'])) {
+ $output[] = '';
+ $output[] = '';
+ }
+ elseif (!isset($variables['src'])) {
+ $output[] = '';
+ $output[] = '';
+ }
+ }
+ else {
+ $output[] = '';
+ $output[] = '';
+ }
+ return implode("\n", $output);
+}
+
+function picture_get_image_dimensions($variables) {
+ // Determine the dimensions of the styled image.
+ $dimensions = array(
+ 'width' => $variables['width'],
+ 'height' => $variables['height'],
+ );
+
+ image_style_transform_dimensions($variables['style_name'], $dimensions);
+
+ return $dimensions;
+}
diff --git a/core/modules/picture/picture_mapping.admin.inc b/core/modules/picture/picture_mapping.admin.inc
new file mode 100644
index 0000000..fbae3f3
--- /dev/null
+++ b/core/modules/picture/picture_mapping.admin.inc
@@ -0,0 +1,93 @@
+render();
+}
+
+/**
+ * Page callback: Presents the picture mapping editing form.
+ *
+ * @param Drupal\picture\PictureMapping $picture_mapping
+ *
+ * @return
+ * A render array for a page containing a list of content.
+ *
+ * @see picture_menu()
+ */
+function picture_mapping_page_edit($picture_mapping) {
+ drupal_set_title(t('Edit picture mapping @label', array('@label' => $picture_mapping->label())), PASS_THROUGH);
+ return entity_get_form($picture_mapping);
+}
+
+/**
+ * Page callback: Provides the new picture mapping addition form.
+ *
+ * @return
+ * A render array for a page containing a list of content.
+ *
+ * @see picture_menu()
+ */
+function picture_mapping_page_add() {
+ $picture_mapping = entity_create('picture_mapping', array());
+ $form = entity_get_form($picture_mapping);
+ return $form;
+}
+
+/**
+ * Page callback: Form constructor for picture action confirmation form.
+ *
+ * @param Drupal\picture\PictureMapping $picture_mapping
+ * @param string $action
+ *
+ * @see picture_menu()
+ */
+function picture_mapping_action_confirm($form, &$form_state, $picture_mapping, $action) {
+ // Always provide entity id in the same form key as in the entity edit form.
+ if (in_array($action, array('delete'))) {
+ $form['id'] = array('#type' => 'value', '#value' => $picture_mapping->id());
+ $form['action'] = array('#type' => 'value', '#value' => $action);
+ $form_state['picture_mapping'] = $picture_mapping;
+ $form = confirm_form($form,
+ t('Are you sure you want to @action the picture_mapping %title?', array('@action' => $action, '%title' => $picture_mapping->label())),
+ 'admin/config/media/picturemapping',
+ $action == 'delete' ? t('This action cannot be undone.') : '',
+ t(drupal_ucfirst($action)),
+ t('Cancel')
+ );
+ }
+ return $form;
+}
+
+/**
+ * Form submission handler for picture_action_confirm().
+ */
+function picture_mapping_action_confirm_submit($form, &$form_state) {
+ $picture_mapping = $form_state['picture_mapping'];
+ $action = $form_state['values']['action'];
+ $picture_mapping->{$action}();
+ $verb = '';
+ switch ($action) {
+ case 'delete':
+ $verb = 'deleted';
+ break;
+ }
+ drupal_set_message(t('Picture mapping %label has been @action.', array('%label' => $picture_mapping->label(), '@action' => $verb)));
+ watchdog('picture', 'Picture mapping %label has been @action.', array('%label' => $picture_mapping->label(), '@action' => $verb), WATCHDOG_NOTICE);
+ $form_state['redirect'] = 'admin/config/media/picturemapping';
+}
diff --git a/core/modules/picture/picturefill/matchmedia.js b/core/modules/picture/picturefill/matchmedia.js
new file mode 100644
index 0000000..adce5ab
--- /dev/null
+++ b/core/modules/picture/picturefill/matchmedia.js
@@ -0,0 +1,2 @@
+/*! matchMedia() polyfill - Test a CSS media type/query in JS. Authors & copyright (c) 2012: Scott Jehl, Paul Irish, Nicholas Zakas. Dual MIT/BSD license */
+window.matchMedia=window.matchMedia||(function(e,f){var c,a=e.documentElement,b=a.firstElementChild||a.firstChild,d=e.createElement("body"),g=e.createElement("div");g.id="mq-test-1";g.style.cssText="position:absolute;top:-100em";d.appendChild(g);return function(h){g.innerHTML='';a.insertBefore(d,b);c=g.offsetWidth==42;a.removeChild(d);return{matches:c,media:h}}})(document);
\ No newline at end of file
diff --git a/core/modules/picture/picturefill/picturefill.js b/core/modules/picture/picturefill/picturefill.js
new file mode 100644
index 0000000..8e4c231
--- /dev/null
+++ b/core/modules/picture/picturefill/picturefill.js
@@ -0,0 +1,126 @@
+/*jshint loopfunc: true, browser: true, curly: true, eqeqeq: true, expr: true, forin: true, latedef: true, newcap: true, noarg: true, trailing: true, undef: true, unused: true */
+/*! Picturefill - Author: Scott Jehl, 2012 | License: MIT/GPLv2 */
+(function( w ){
+
+ // Enable strict mode.
+ "use strict";
+
+ // Test if `` is supported natively, if so, exit.
+ if (!!(w.document.createElement('picture') && w.document.createElement('source') && w.HTMLPictureElement)) {
+ return;
+ }
+
+ w.picturefill = function() {
+ // Copy attributes from the source to the destination.
+ function _copyAttributes(src, tar) {
+ if (src.getAttribute('width') && src.getAttribute('height')) {
+ tar.width = src.getAttribute('width');
+ tar.height = src.getAttribute('height');
+ }
+ }
+
+ // Get all picture tags.
+ var ps = w.document.getElementsByTagName('picture');
+
+ // Loop the pictures.
+ for (var i = 0, il = ps.length; i < il; i++ ) {
+ var sources = ps[i].getElementsByTagName('source');
+ var picImg = null;
+ var matches = [];
+
+ // If no sources are found, they're likely erased from the DOM.
+ // Try finding them inside comments.
+ if (!sources.length) {
+ var picText = ps[i].innerHTML;
+ var frag = w.document.createElement('div');
+ // For IE9, convert the source elements to divs.
+ var srcs = picText.replace(/(<)source([^>]+>)/gmi, '$1div$2').match(/
]+>/gmi);
+
+ frag.innerHTML = srcs.join('');
+ sources = frag.getElementsByTagName('div');
+ }
+
+ // See which sources match.
+ for (var j = 0, jl = sources.length; j < jl; j++ ) {
+ var media = sources[j].getAttribute('media');
+ // if there's no media specified, OR w.matchMedia is supported
+ if (!media || (w.matchMedia && w.matchMedia(media).matches)) {
+ matches.push(sources[j]);
+ }
+ }
+
+ if (matches.length) {
+ // Grab the most appropriate (last) match.
+ var match = matches.pop();
+ var srcset = match.getAttribute('srcset');
+
+ // Find any existing img element in the picture element.
+ picImg = ps[i].getElementsByTagName('img')[0];
+
+ // Add a new img element if one doesn't exists.
+ if (!picImg) {
+ picImg = w.document.createElement('img');
+ picImg.alt = ps[i].getAttribute('alt');
+ ps[i].appendChild(picImg);
+ }
+
+ // Source element uses a srcset.
+ if (srcset) {
+ var screenRes = w.devicePixelRatio || 1;
+ // Split comma-separated `srcset` sources into an array.
+ sources = srcset.split(', ');
+
+ // Loop through each source/resolution in srcset.
+ for (var res = sources.length, r = res - 1; r >= 0; r-- ) {
+ // Remove any leading whitespace, then split on spaces.
+ var source = sources[ r ].replace(/^\s*/, '').replace(/\s*$/, '').split(' ');
+ // Parse out the resolution for each source in `srcset`.
+ var resMatch = parseFloat(source[1], 10);
+
+ if (screenRes >= resMatch) {
+ if (picImg.getAttribute('src') !== source[0]) {
+ var newImg = document.createElement('img');
+
+ newImg.src = source[0];
+ // When the image is loaded, set a width equal to that of the
+ // original’s intrinsic width divided by the screen resolution.
+ newImg.onload = function() {
+ // Clone the original image into memory so the width is
+ // unaffected by page styles.
+ var w = this.cloneNode(true).width;
+ if (w > 0) {
+ this.width = (w / resMatch);
+ }
+ };
+ // Copy width and height from the source tag to the img element.
+ _copyAttributes(match, newImg);
+ picImg.parentNode.replaceChild(newImg, picImg);
+ }
+ // We’ve matched, so bail out of the loop here.
+ break;
+ }
+ }
+ } else {
+ // No srcset used, so just use the 'src' value.
+ picImg.src = match.getAttribute('src');
+ // Copy width and height from the source tag to the img element.
+ _copyAttributes(match, picImg);
+ }
+ }
+ }
+ };
+
+ // Run on resize and domready (w.load as a fallback)
+ if (w.addEventListener) {
+ w.addEventListener('resize', w.picturefill, false);
+ w.addEventListener('DOMContentLoaded', function() {
+ w.picturefill();
+ // Run once only.
+ w.removeEventListener('load', w.picturefill, false);
+ }, false);
+ w.addEventListener('load', w.picturefill, false);
+ }
+ else if (w.attachEvent) {
+ w.attachEvent('onload', w.picturefill);
+ }
+})(this);