diff --git a/core/modules/edit/css/edit.css b/core/modules/edit/css/edit.css new file mode 100644 index 0000000..b270165 --- /dev/null +++ b/core/modules/edit/css/edit.css @@ -0,0 +1,405 @@ +/** + * Animations. + */ +.edit-animate-invisible { + opacity: 0; +} + +.edit-animate-fast { +-webkit-transition: all .2s ease; + -moz-transition: all .2s ease; + -ms-transition: all .2s ease; + -o-transition: all .2s ease; + transition: all .2s ease; +} + +.edit-animate-default { + -webkit-transition: all .4s ease; + -moz-transition: all .4s ease; + -ms-transition: all .4s ease; + -o-transition: all .4s ease; + transition: all .4s ease; +} + +.edit-animate-slow { +-webkit-transition: all .6s ease; + -moz-transition: all .6s ease; + -ms-transition: all .6s ease; + -o-transition: all .6s ease; + transition: all .6s ease; +} + +.edit-animate-delay-veryfast { + -webkit-transition-delay: .05s; + -moz-transition-delay: .05s; + -ms-transition-delay: .05s; + -o-transition-delay: .05s; + transition-delay: .05s; +} + +.edit-animate-delay-fast { + -webkit-transition-delay: .2s; + -moz-transition-delay: .2s; + -ms-transition-delay: .2s; + -o-transition-delay: .2s; + transition-delay: .2s; +} + +.edit-animate-disable-width { + -webkit-transition: width 0s; + -moz-transition: width 0s; + -ms-transition: width 0s; + -o-transition: width 0s; + transition: width 0s; +} + +.edit-animate-only-visibility { + -webkit-transition: opacity .2s ease; + -moz-transition: opacity .2s ease; + -ms-transition: opacity .2s ease; + -o-transition: opacity .2s ease; + transition: opacity .2s ease; +} + + + + +/** + * Toolbar. + */ +.icon-edit:before { + background-image: url("../images/icon-edit.png"); +} +.icon-edit:active:before, +.active .icon-edit:before { + background-image: url("../images/icon-edit-active.png"); +} +.toolbar .tray.edit.active { + z-index: 340; +} +.toolbar .icon-edit.edit-nothing-editable-hidden { + display: none; +} + + + + +/** + * Edit mode: overlay + candidate editables + editables being edited. + * + * Note: every class is prefixed with "edit-" to prevent collisions with modules + * or themes. In IPE-specific DOM subtrees, this is not necessary. + */ + +#edit_overlay { + position: fixed; + z-index: 250; + width: 100%; + height: 100%; + background-color: #fff; + background-color: rgba(255,255,255,.5); + top: 0; + left: 0; +} + +/* Editable. */ +.edit-editable { + z-index: 300; + position: relative; +} +.edit-editable:focus { + outline: none; +} +.edit-field.edit-editable, +.edit-field.edit-type-direct .edit-editable { + box-shadow: 0 0 1px 1px #4d9de9; +} + +/* Highlighted (hovered) editable. */ +.edit-editable.edit-highlighted { + min-width: 200px; +} +.edit-field.edit-editable.edit-highlighted, +.edit-form.edit-editable.edit-highlighted, +.edit-field.edit-type-direct .edit-editable.edit-highlighted { + box-shadow: 0 0 1px 1px #0199ff, 0 0 3px 3px rgba(153, 153, 153, .5); +} +.edit-field.edit-editable.edit-highlighted.edit-validation-error, +.edit-form.edit-editable.edit-highlighted.edit-validation-error, +.edit-field.edit-type-direct .edit-editable.edit-highlighted.edit-validation-error { + box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5); +} +.edit-form.edit-editable .form-item .error { + border: 1px solid #eea0a0; +} + + +/* Editing (focused) editable. */ +.edit-form.edit-editable.edit-editing, +.edit-field.edit-type-direct .edit-editable.edit-editing { + /* In the latest design, there's no special styling when editing as opposed to + * just hovering. + * This will be necessary again for http://drupal.org/node/1844220. + */ +} + + + + +/** + * Edit mode: modal. + */ +#edit_modal { + z-index: 350; + position: fixed; + top: 40%; + left: 40%; + box-shadow: 3px 3px 5px #333; + background-color: white; + border: 1px solid #0199ff; + font-family: 'Droid sans', 'Lucida Grande', sans-serif; +} + +#edit_modal .main { + font-size: 130%; + margin: 25px; + padding-left: 40px; + background: transparent url('../images/attention.png') no-repeat; +} + +#edit_modal .actions { + border-top: 1px solid #ddd; + padding: 3px inherit; + text-align: right; + background: #f5f5f5; +} + +/* Modal active: prevent user from interacting with toolbar & editables. */ +.edit-form-container.edit-belowoverlay, +.edit-toolbar-container.edit-belowoverlay, +.edit-validation-errors.edit-belowoverlay { + z-index: 210; +} +.edit-editable.edit-belowoverlay { + z-index: 200; +} + + + + +/** + * Edit mode: type=direct. + */ +.edit-validation-errors { + z-index: 300; + position: relative; +} + +.edit-validation-errors .messages.error { + position: absolute; + top: 6px; + left: -5px; + margin: 0; + border: none; + box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5); + background-color: white; +} + + + + +/** + * Edit mode: type=form. + */ +#edit_backstage { + display: none; +} + +.edit-form { + position: absolute; + z-index: 300; + box-shadow: 0 0 30px 4px #4f4f4f; + max-width: 35em; +} + +.edit-form .placeholder { + min-height: 22px; +} + +/* Default form styling overrides. */ +.edit-form form { padding: 1em; } +.edit-form .form-item { margin: 0; } +.edit-form .form-wrapper { margin: .5em; } +.edit-form .form-actions { display: none; } +.edit-form input { max-width: 100%; } + + + + +/** + * Edit mode: toolbars + */ + +/* Trick: wrap statically positioned elements in relatively positioned element + without changing its location. This allows us to absolutely position the + toolbar. +*/ +.edit-toolbar-container, +.edit-form-container { + position: relative; + padding: 0; + border: 0; + margin: 0; + vertical-align: baseline; + z-index: 310; +} +.edit-toolbar-container { + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; +} + +.edit-toolbar-heightfaker { + height: auto; + position: absolute; + bottom: 1px; + box-shadow: 0 0 1px 1px #0199ff, 0 0 3px 3px rgba(153, 153, 153, .5); + background: #fff; +} + +/* The toolbar; these are not necessarily visible. */ +.edit-toolbar { + position: relative; + height: 100%; + font-family: 'Droid sans', 'Lucida Grande', sans-serif; +} +.edit-toolbar-heightfaker { + clip: rect(-1000px, 1000px, auto, -1000px); /* Remove bottom box-shadow. */ +} +/* Exception: when used for a directly WYSIWYG editable field that is actively + being edited. */ +.edit-type-direct-with-wysiwyg .edit-editing .edit-toolbar-heightfaker { + width: 100%; + clip: auto; +} + + +/* The toolbar contains toolgroups; these are visible. */ +.edit-toolgroup { + float: left; /* LTR */ +} + +/* Info toolgroup. */ +.edit-toolgroup.info { + float: left; /* LTR */ + font-weight: bolder; + padding: 0 5px; + background: #fff url('../images/throbber.gif') no-repeat -60px 60px; +} +.edit-toolgroup.info.loading { + padding-right: 35px; + background-position: 90% 50%; +} + +/* Operations toolgroup. */ +.edit-toolgroup.ops { + float: right; /* LTR */ + margin-left: 5px; +} + +.edit-toolgroup.wysiwyg-tabs { + float: right; +} +.edit-toolgroup.wysiwyg { + clear: left; + width: 100%; + padding-left: 0; +} + + + +/** + * Edit mode: buttons (in both modal and toolbar). + */ +#edit_modal a, +.edit-toolbar a { + float: left; /* LTR */ + display: block; + height: 21px; + min-width: 21px; + padding: 3px 6px 3px 6px; + margin: 4px 5px 1px 0; + border: 1px solid #fff; + border-radius: 3px; + color: white; + text-decoration: none; + font-size: 13px; +} +#edit_modal a { + float: none; + display: inline-block; +} + +#edit_modal a:link, +#edit_modal a:visited, +#edit_modal a:hover, +#edit_modal a:active, +.edit-toolbar a:link, +.edit-toolbar a:visited, +.edit-toolbar a:hover, +.edit-toolbar a:active { + text-decoration: none; +} + +/* Button with icons. */ +#edit_modal a span, +.edit-toolbar a span { + width: 22px; + height: 19px; + display: block; + float: left; +} +.edit-toolbar a span.close { + background: url('../images/close.png') no-repeat 3px 2px; +} + +.edit-toolbar a.blank-button { + color: black; +} + +#edit_modal a.blue-button, +.edit-toolbar a.blue-button { + color: white; + background-image: -webkit-linear-gradient(top, #6fc2f2 0%, #4e97c0 100%); + background-image: -moz-linear-gradient(top, #6fc2f2 0%, #4e97c0 100%); + background-image: linear-gradient(top, #6fc2f2 0%, #4e97c0 100%); + border-radius: 5px; +} + +#edit_modal a.gray-button, +.edit-toolbar a.gray-button { + color: #666; + background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #ccc 100%); + background-image: -moz-linear-gradient(top, #f5f5f5 0%, #ccc 100%); + background-image: linear-gradient(top, #f5f5f5 0%, #ccc 100%); + border-radius: 5px; +} + +#edit_modal a.blue-button:hover, +.edit-toolbar a.blue-button:hover, +#edit_modal a.blue-button:active, +.edit-toolbar a.blue-button:active { + border: 1px solid #55a5d3; + box-shadow: 0 2px 1px rgba(0,0,0,0.2); +} + +#edit_modal a.gray-button:hover, +.edit-toolbar a.gray-button:hover, +#edit_modal a.gray-button:active, +.edit-toolbar a.gray-button:active { + border: 1px solid #cdcdcd; + box-shadow: 0 2px 1px rgba(0,0,0,0.1); +} diff --git a/core/modules/edit/edit.form.inc b/core/modules/edit/edit.form.inc new file mode 100644 index 0000000..cdf9935 --- /dev/null +++ b/core/modules/edit/edit.form.inc @@ -0,0 +1,105 @@ + $form_state['field_name']); + field_attach_form($entity->entityType(), $entity, $form, $form_state, $langcode, $options); + + $form['#validate'][] = 'edit_field_form_validate'; + + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => t('Save'), + ); + + // Simplify the form. + _simplify_edit_field_edit_form($form); + + return $form; +} + +/** + * Simplifies the field edit form for in-place editing. + * + * @param array $form + * An associative array containing the structure of the form. + */ +function _simplify_edit_field_edit_form(array &$form) { + $elements = element_children($form); + + // Required internal form properties. + $internal_elements = array('actions', 'form_build_id', 'form_token', 'form_id'); + + // Calculate the remaining form elements. + $remaining_elements = array_diff($elements, $internal_elements); + + // Only simplify the form if there is a single element remaining. + if (count($remaining_elements) === 1) { + $element = $remaining_elements[0]; + + if ($form[$element]['#type'] == 'container') { + $language = $form[$element]['#language']; + $children = element_children($form[$element][$language]); + + // Certain fields require different processing depending on the form + // structure. + if (count($children) == 0) { + // Checkbox elements don't have a title. + if ($form[$element][$language]['#type'] != 'checkbox') { + $form[$element][$language]['#title_display'] = 'invisible'; + } + } + elseif (count($children) == 1) { + $form[$element][$language][0]['value']['#title_display'] = 'invisible'; + + // UX improvement: make the number of rows of textarea form elements + // fit the content. (i.e. no wads of whitespace) + if (isset($form[$element][$language][0]['value']['#type']) + && $form[$element][$language][0]['value']['#type'] == 'textarea') + { + $lines = count(explode("\n", $form[$element][$language][0]['value']['#default_value'])); + $form[$element][$language][0]['value']['#rows'] = $lines + 1; + } + } + } + } + + // Make it easy for the JavaScript to identify the submit button. + $form['actions']['submit']['#attributes'] = array('class' => array('edit-form-submit')); +} + +/** + * Validate field editing form. + * + * @see edit_field_form() + * + * @todo: BLOCKED_ON(Drupal core, http://drupal.org/node/1846648) + * Clean up once that issue is solved. + */ +function edit_field_form_validate(array $form, array &$form_state) { + $entity = $form_state['entity']; + $options = array('field_name' => $form_state['field_name']); + + // 'submit' in D8 is for "building the entity object", not for actual + // submission. It appears though that if there were no validation errors, it + // is submitted automatically. + field_attach_submit($entity->entityType(), $entity, $form, $form_state, $options); + + // Validation. + field_attach_form_validate($entity->entityType(), $entity, $form, $form_state, $options); +} diff --git a/core/modules/edit/edit.info b/core/modules/edit/edit.info new file mode 100644 index 0000000..3328601 --- /dev/null +++ b/core/modules/edit/edit.info @@ -0,0 +1,7 @@ +name = Edit +description = In-place content editing. +package = Core +core = 8.x + +dependencies[] = field +dependencies[] = toolbar diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module new file mode 100644 index 0000000..dd4feaf --- /dev/null +++ b/core/modules/edit/edit.module @@ -0,0 +1,390 @@ + array(TRUE), + 'access callback' => TRUE, + 'page callback' => 'edit_field_edit', + 'page arguments' => array(3, 4, 5, 6, 7), + 'theme callback' => 'ajax_base_page_theme', + 'file' => 'edit.pages.inc', + ); + $items['admin/render-without-transformations/field/%/%/%/%/%'] = array( + // Access is controlled after we have inspected the entity, which can't + // easily happen until after the callback. + 'access arguments' => array(TRUE), + 'access callback' => TRUE, + 'page callback' => 'edit_text_field_render_without_transformation_filters', + 'page arguments' => array(3, 4, 5, 6, 7), + 'theme callback' => 'ajax_base_page_theme', + 'file' => 'edit.pages.inc', + ); + + return $items; +} + +/** + * Implements hook_toolbar(). + */ +function edit_toolbar() { + if (path_is_admin(current_path())) { + return; + } + + $tab['edit'] = array( + 'tab' => array( + 'title' => t('Edit'), + 'href' => '', + 'html' => FALSE, + 'attributes' => array( + 'class' => array('icon', 'icon-edit', 'edit-nothing-editable-hidden'), + ), + ), + 'tray' => array( + '#heading' => t('In-place editing operations'), + 'view_edit_toggle' => array( + '#theme' => 'links__toolbar_edit', + '#attributes' => array( + 'id' => 'edit_view-edit-toggles', + 'class' => 'menu', + ), + '#links' => array( + 'view' => array( + 'title' => t('View'), + 'href' => request_path(), + 'fragment' => 'view', + 'attributes' => array( + 'class' => array('edit_view-edit-toggle', 'edit-view'), + ), + ), + 'edit' => array( + 'title' => t('Quick edit'), + 'href' => request_path(), + 'fragment' => 'quick-edit', + 'attributes' => array( + 'class' => array('edit_view-edit-toggle', 'edit-edit'), + ), + ), + ), + '#attached' => array( + 'library' => array( + array('edit', 'edit'), + ), + ), + ), + ), + ); + + return $tab; +} + +/** + * Implements hook_library(). + */ +function edit_library_info() { + $path = drupal_get_path('module', 'edit'); + $options = array( + 'scope' => 'footer', + 'attributes' => array('defer' => TRUE), + ); + $libraries['edit'] = array( + 'title' => 'Edit: in-place editing', + 'website' => 'http://drupal.org/project/edit', + 'version' => VERSION, + 'js' => array( + // Core. + $path . '/js/edit.js' => $options, + $path . '/js/app.js' => $options, + // Routers. + $path . '/js/routers/edit-router.js' => $options, + // Models. + $path . '/js/models/edit-app-model.js' => $options, + // Views. + $path . '/js/views/propertyeditordecoration-view.js' => $options, + $path . '/js/views/menu-view.js' => $options, + $path . '/js/views/modal-view.js' => $options, + $path . '/js/views/overlay-view.js' => $options, + $path . '/js/views/toolbar-view.js' => $options, + // Backbone.sync implementation on top of Drupal forms. + $path . '/js/backbone.drupalform.js' => $options, + // VIE service. + $path . '/js/viejs/EditService.js' => $options, + // Create.js subclasses. + $path . '/js/createjs/editable.js' => $options, + $path . '/js/createjs/storage.js' => $options, + $path . '/js/createjs/editingWidgets/formwidget.js' => $options, + $path . '/js/createjs/editingWidgets/drupalcontenteditablewidget.js' => $options, + // Other. + $path . '/js/util.js' => $options, + $path . '/js/theme.js' => $options, + // Basic settings. + array( + 'data' => array('edit' => array( + 'fieldFormURL' => url('admin/edit/field/!entity_type/!id/!field_name/!langcode/!view_mode'), + 'rerenderProcessedTextURL' => url('admin/render-without-transformations/field/!entity_type/!id/!field_name/!langcode/!view_mode'), + 'context' => 'body', + )), + 'type' => 'setting', + ), + ), + 'css' => array( + $path . '/css/edit.css' => array(), + ), + 'dependencies' => array( + array('system', 'jquery'), + array('system', 'underscore'), + array('system', 'backbone'), + array('system', 'vie.core'), + array('system', 'create.editonly'), + array('system', 'jquery.form'), + array('system', 'drupal.form'), + array('system', 'drupal.ajax'), + array('system', 'drupalSettings'), + ), + ); + + return $libraries; +} + +/** + * Implements hook_field_attach_view_alter(). + */ +function edit_field_attach_view_alter(&$output, $context) { + // Special case for this special mode. + if ($context['display'] == 'edit-render-without-transformation-filters') { + $children = element_children($output); + $field_name = reset($children); + $langcode = $output[$field_name]['#language']; + foreach (array_keys($output[$field_name]['#items']) as $item) { + $text = $output[$field_name]['#items'][$item]['value']; + $format_id = $output[$field_name]['#items'][$item]['format']; + $untransformed = check_markup($text, $format_id, $langcode, FALSE, array(FILTER_TYPE_TRANSFORM_REVERSIBLE, FILTER_TYPE_TRANSFORM_IRREVERSIBLE)); + $output[$field_name][$item]['#markup'] = $untransformed; + } + } +} + +/** + * Implements hook_preprocess_HOOK() for field.tpl.php. + */ +function edit_preprocess_field(&$variables) { + $entity = $variables['element']['#object']; + $field_name = $variables['element']['#field_name']; + $langcode = $variables['element']['#language']; + $view_mode = $variables['element']['#view_mode']; + $formatter_type = $variables['element']['#formatter']; + $items = $entity->{$field_name}[$langcode];; + $instance = field_info_instance($entity->entityType(), $field_name, $entity->bundle()); + + $entity_access = edit_entity_access('update', $entity->entityType(), $entity); + $field_access = field_access('edit', $field_name, $entity->entityType(), $entity); + $editor = _edit_get_field_editor($items, $instance, $formatter_type); + if ($entity_access && $field_access && $editor != 'disabled') { + // Mark this field as editable and provide metadata through data- attributes. + $variables['attributes']['data-edit-field-label'] = $instance->definition['label']; + $variables['attributes']['data-edit-id'] = $entity->entityType() . ':' . $entity->id() . ':' . $field_name . ':' . $langcode . ':' . $view_mode; + $variables['attributes']['class'][] = 'edit-field'; + $variables['attributes']['class'][] = 'edit-allowed'; + $variables['attributes']['class'][] = 'edit-type-' . $editor; + if ($editor == 'direct-with-wysiwyg') { + $variables['attributes']['class'][] = 'edit-type-direct'; + $format_id = $entity->{$field_name}[$langcode][0]['format']; + _edit_preprocess_field_wysiwyg($variables, $format_id); + } + } +} + +/** + * Sets attributes on a field that have 'direct-with-wysiwyg' editor. + * + * @param array $variables + * An associative array containing: the key 'attributes'. See the + * theme_field() function for information about these variables. + * @param string $format_id + * A text format id. + * + * @see theme_field() + */ +function _edit_preprocess_field_wysiwyg(&$variables, $format_id) { + // Let the WYSIWYG editor know the text format. + $variables['attributes']['data-edit-text-format'] = $format_id; + + // Let the JavaScript logic know whether transformation filters are used + // in this format, so it can decide whether to re-render the text or not. + $filter_types = filter_get_filter_types_by_format($format_id); + $transformation_filter_types = array( + FILTER_TYPE_TRANSFORM_REVERSIBLE, + FILTER_TYPE_TRANSFORM_IRREVERSIBLE + ); + if (count(array_intersect($transformation_filter_types, $filter_types))) { + $variables['attributes']['class'][] = 'edit-text-with-transformation-filters'; + } + else { + $variables['attributes']['class'][] = 'edit-text-without-transformation-filters'; + } +} + +/** + * Determines editor given a field, its instance info and its formatter. + * + * @param array $field + * The field's field array. + * @param FieldInstance $instance + * The field's instance info. + * @param string $formatter_type + * The field's formatter type name. + * + * @return string + * The editor: 'disabled', 'form', 'direct' or 'direct-with-wysiwyg'. + */ +function _edit_get_field_editor($items, FieldInstance $instance, $formatter_type) { + $field_name = $instance['field_name']; + + // If the formatter doesn't contain the edit property, default it to 'form' + // editor, which should always work. + $formatter_info = field_info_formatter_types($formatter_type); + if (empty($formatter_info['edit']['editor'])) { + $formatter_info['edit']['editor'] = 'form'; + } + + $editor = $formatter_info['edit']['editor']; + + // If editing is explicitly disabled for this field, return early to avoid + // any further processing. + if ($editor == 'disabled') { + return; + } + + // If directly editable, check the cardinality. If the cardinality is greater + // than 1, use a form to edit the field. + if ($editor == 'direct') { + $field = field_info_field($field_name); + if ($field['cardinality'] != 1) { + $editor = 'form'; + } + } + + // If still directly editable, check whether "regular" direct editing (almost + // bare contentEditable) editing should be used or WYSIWYG-based direct + // editing should be used. In the latter case + if ($editor == 'direct') { + // If this field is configured to not use text processing; it is plain text + // "regular" direct editing should be used, which is already set. + // On the other hand, if it is configured to use text processing; then we + // must check whether 'direct-with-wysiwyg' or 'form' editor should be + // used. + if (!empty($instance['settings']['text_processing'])) { + $format_id = $items[0]['format']; + $editor = _edit_wysiwyg_get_field_editor($format_id); + } + } + + return $editor; +} + +/** + * Determines editor given a directly editable field with text processing. + * + * Given a text field (with cardinality 1) that defaults to 'direct' editor + * and has text processing enabled, check whether the text format allows it to + * use WYSIWYG-powered direct editing or whether 'form' based editing needs to + * be used. + * + * @param string|NULL $format_id + * The field's current text format. + * + * @return string + * The editor: 'direct-with-wysiwyg' or 'form'. + */ +function _edit_wysiwyg_get_field_editor($format_id = NULL) { + $wysiwyg_plugin = &drupal_static(__FUNCTION__); + + // If no format is assigned yet, (e.g. when the field is still empty (NULL)), + // then provide form-based editing, so that the user is able to select a text + // format. (Direct editing doesn't allow the user to change the format.) + if (empty($format_id)) { + return 'form'; + } + + // NOTE: this code will pick the first processed text PropertyEditor widget + // plug-in that is available and consider that the only available choice. + // @todo: make it possible to have multiple processed text PropertyEditor + // widgets. + if (!isset($wysiwyg_plugin) && isset($format_id)) { + $definitions = drupal_container()->get('plugin.manager.edit.processed_text_editor')->getDefinitions(); + if (count($definitions)) { + $plugin_ids = array_keys($definitions); + $plugin_id = $plugin_ids[0]; + $wysiwyg_plugin = drupal_container()->get('plugin.manager.edit.processed_text_editor')->createInstance($plugin_id); + $wysiwyg_plugin->settingsAdded = FALSE; + } + } + + // If no WYSIWYG editor is available, then fall back to form-based editing. + if (!isset($wysiwyg_plugin)) { + return 'form'; + } + // If the WYSIWYG editor is not compatible with the current format, then fall + // back to form-based editing. + else { + $match = $wysiwyg_plugin->checkFormatCompatibility($format_id); + if (!$match) { + return 'form'; + } + else if ($match) { + // Only load the WYSIWYG editor's JavaScript if it hasn't been already. + if ($wysiwyg_plugin->settingsAdded === FALSE) { + $definition = $wysiwyg_plugin->getDefinition(); + if (!empty($definition['library'])) { + drupal_add_library($definition['library']['module'], $definition['library']['name']); + } + $wysiwyg_plugin->addJsSettings(); + + // Let Create.js know which WYSIWYG editor widget it should use. + if (!empty($definition['propertyEditorName'])) { + drupal_add_js(array('edit' => array( + 'wysiwygEditorWidgetName' => $definition['propertyEditorName'], + )), 'setting'); + } + + $wysiwyg_plugin->settingsAdded = TRUE; + } + return 'direct-with-wysiwyg'; + } + } +} diff --git a/core/modules/edit/edit.pages.inc b/core/modules/edit/edit.pages.inc new file mode 100644 index 0000000..f8af99c --- /dev/null +++ b/core/modules/edit/edit.pages.inc @@ -0,0 +1,165 @@ +bundle()); + if (empty($field_instance)) { + throw new NotFoundHttpException(); + } + + $form_state = array( + 'entity' => $entity, + 'field_name' => $field_name, + 'langcode' => $langcode, + 'no_redirect' => TRUE, + 'build_info' => array('args' => array()), + ); + form_load_include($form_state, 'inc', 'edit', 'edit.form'); + $form = drupal_build_form('edit_field_form', $form_state); + + if (!empty($form_state['executed'])) { + // Retrieve the updated entity, save it and render only the modified field. + $entity = $form_state['entity']; + $entity->save(); + $output = field_view_field($entity->entityType(), $entity, $field_name, $view_mode, $langcode); + + $response->addCommand(new FieldFormSavedCommand(drupal_render($output))); + } + else { + $response->addCommand(new FieldFormCommand(drupal_render($form))); + + $errors = form_get_errors(); + if (count($errors)) { + $response->addCommand(new FieldFormValidationErrorsCommand(theme('status_messages'))); + } + } + + // When working with a hidden form, we don't want any CSS or JS to be loaded. + if (isset($_POST['nocssjs']) && $_POST['nocssjs'] === 'true') { + drupal_static_reset('drupal_add_css'); + drupal_static_reset('drupal_add_js'); + } + + return $response; +} + +/** + * Page callback: render a processed text field without transformation filters. + * + * @param string $entity_type + * The entity type of the entity of which a processed text field is being + * rerendered. + * @param int $entity_id + * The entity ID of the entity of which a processed text field is being + * rerendered. + * @param string $field_name + * The name of the (processed text) field that that is being rerendered + * @param string $langcode + * The name of the language for which the processed text field is being + * rererendered. + * @param string $view_mode + * The view mode the processed text field should be rerendered in. + * @return array + * A render array. + */ +function edit_text_field_render_without_transformation_filters($entity_type, $entity_id, $field_name, $langcode, $view_mode) { + $response = new AjaxResponse(); + + // Ensure the entity type is valid. + if (empty($entity_type)) { + throw new NotFoundHttpException(); + } + + $entity_info = entity_get_info($entity_type); + if (!$entity_info) { + throw new NotFoundHttpException(); + } + + $entity = entity_load($entity_type, $entity_id); + if (!$entity) { + throw new NotFoundHttpException(); + } + + // Ensure a valid language code is set. + $langcode = field_valid_language($langcode); + + // Ensure access to update this particular entity is granted. + if (!edit_entity_access('update', $entity_type, $entity)) { + throw new AccessDeniedHttpException(); + } + + // Ensure access to update this particular field is granted. + if (!field_access('edit', $field_name, $entity_type, $entity)) { + throw new AccessDeniedHttpException(); + } + + $field_instance = field_info_instance($entity_type, $field_name, $entity->bundle()); + if (empty($field_instance)) { + throw new NotFoundHttpException(); + } + + // Render the field in our custom display mode; retrieve the re-rendered + // markup, this is what we're after. + $field_output = field_view_field($entity_type, $entity, $field_name, 'edit-render-without-transformation-filters'); + $output = $field_output[0]['#markup']; + $response->addCommand(new FieldRenderedWithoutTransformationFiltersCommand($output)); + + return $response; +} diff --git a/core/modules/edit/images/attention.png b/core/modules/edit/images/attention.png new file mode 100644 index 0000000..6a35d1d --- /dev/null +++ b/core/modules/edit/images/attention.png @@ -0,0 +1,4 @@ +PNG + + IHDR *}`PLTEl՟FZݱ|В8ʂx՜nϏ۫@EN;[cgUH7 tRNS =g- Su- 4_IDATx^}ʇ @Qzn/g!ul6 ; 0!f>>Ǐ kν_΁j㜻!0-@>8,i vҤrlWn?B(ijk*yT%Pvs=b_v>@?k& +a|NciKBFUD^']d`5+5P :IENDB` \ No newline at end of file diff --git a/core/modules/edit/images/close.png b/core/modules/edit/images/close.png new file mode 100644 index 0000000..e3f98b8 --- /dev/null +++ b/core/modules/edit/images/close.png @@ -0,0 +1,4 @@ +PNG + + IHDR(-S`PLTE>+tRNS```00miIDATx^=   H4ͼ!KsfQGx"LCyל׽(ux;z KA.Jo +E wy/2cdD@Ҕ L؅O%8F?QIENDB` \ No newline at end of file diff --git a/core/modules/edit/images/icon-edit-active.png b/core/modules/edit/images/icon-edit-active.png new file mode 100644 index 0000000..ad84761 --- /dev/null +++ b/core/modules/edit/images/icon-edit-active.png @@ -0,0 +1,3 @@ +PNG + + IHDRj `PLTE[tRNS@ P00p`ϟDƙIDATxe DQ8Ϩ/BDU9xV+D\?x@qWcF8wicS B}?v;Vf.V$JgX=Kضp0XS"iRw\:LL\~;Z5wu 5E)LIENDB` \ No newline at end of file diff --git a/core/modules/edit/images/icon-edit.png b/core/modules/edit/images/icon-edit.png new file mode 100644 index 0000000..4f0dcc2 --- /dev/null +++ b/core/modules/edit/images/icon-edit.png @@ -0,0 +1,5 @@ +PNG + + IHDRj PLTE̻ʪ̡̜ˠ̣̽¼ǷʨªZ(e+tRNSϟ `π@`0p0p`0PϟϟcIDATxeW0{W +H"ʵ,y {Hpyo?mf,RBRxB vL;&LPJaRb\(Tbn(1wϔJ)ԈkS +58äT^4P6c}[i <ާ'-+HP>KIENDB` \ No newline at end of file diff --git a/core/modules/edit/images/throbber.gif b/core/modules/edit/images/throbber.gif new file mode 100644 index 0000000..f2603e8 --- /dev/null +++ b/core/modules/edit/images/throbber.gif @@ -0,0 +1,6 @@ +GIF89aŽ{{{! NETSCAPE2.0! ,@`)KkŏA|ad0L9~\8L Ǹ0, i{qBC~H'JRĨ`f4&a! ,` sɺ(t34M!-0,l#))9 !q(i<hB 3˥(`  ,9%cm +b0AY_e! ,dRj:ړtG8$c02P hi,ǑQ0X[LEck5`ڭP2F! a @q dfz{lQzK! ,`BjR:$BPFq(ˢ J@0i-2͇ Qck6[G`m:pQ4fqow! ,d1j}MS@S\ H9#IrPØi8f..r$ł|nl +*T\![͂l,Q0@(KFMO{ ql{! ,^I 3a!P(Ol~`8 LÇ!@$Lgx|f ,`"`Jʼn"cUP +GAw< tz! ,h9C4kk\ &I&l )F-P@ch H!ш4< +  x@R`0C"8h0BfgX(rc}Y + 1!,aйҚX]mKl[)"aĢ\*"$t& #9@1pP`,`I,8 S 8U,Q`(d `3-qH$ 1T{; \ No newline at end of file diff --git a/core/modules/edit/js/app.js b/core/modules/edit/js/app.js new file mode 100644 index 0000000..c5d5be5 --- /dev/null +++ b/core/modules/edit/js/app.js @@ -0,0 +1,318 @@ +/** + * @file + * A Backbone View that is the central app controller. + */ +(function ($, _, Backbone, Drupal, VIE) { + +"use strict"; + + Drupal.edit = Drupal.edit || {}; + Drupal.edit.EditAppView = Backbone.View.extend({ + vie: null, + domService: null, + + // Configuration for state handling. + states: [], + activeEditorStates: [], + singleEditorStates: [], + + // State. + $entityElements: [], + + /** + * Implements Backbone Views' initialize() function. + */ + initialize: function() { + _.bindAll(this, 'appStateChange', 'acceptEditorStateChange', 'editorStateChange'); + + // VIE instance for Edit. + this.vie = new VIE(); + // Use our custom DOM parsing service until RDFa is available. + this.vie.use(new this.vie.EditService()); + this.domService = this.vie.service('edit'); + + // Instantiate configuration for state handling. + this.states = [ + null, 'inactive', 'candidate', 'highlighted', + 'activating', 'active', 'changed', 'saving', 'saved', 'invalid' + ]; + this.activeEditorStates = ['activating', 'active']; + this.singleEditorStates = _.union(['highlighted'], this.activeEditorStates); + + // Use Create's Storage widget. + this.$el.createStorage({ + vie: this.vie, + editableNs: 'createeditable' + }); + + // Instantiate an EditableEntity widget for each property. + var that = this; + this.$entityElements = this.domService.findSubjectElements().each(function() { + $(this).createEditable({ + vie: that.vie, + disabled: true, + state: 'inactive', + acceptStateChange: that.acceptEditorStateChange, + statechange: function(event, data) { + that.editorStateChange(data.previous, data.current, data.propertyEditor); + }, + decoratePropertyEditor: function(data) { + that.decorateEditor(data.propertyEditor); + } + }); + }); + + // Instantiate OverlayView + var overlayView = new Drupal.edit.views.OverlayView({ + model: this.model + }); + + // Instantiate MenuView + var editMenuView = new Drupal.edit.views.MenuView({ + el: this.el, + model: this.model + }); + + // When view/edit mode is toggled in the menu, update the editor widgets. + this.model.on('change:isViewing', this.appStateChange); + }, + + /** + * Sets the state of PropertyEditor widgets when edit mode begins or ends. + * + * Should be called whenever EditAppModel's "isViewing" changes. + */ + appStateChange: function() { + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133, https://github.com/bergie/create/issues/140) + // We're currently setting the state on EditableEntity widgets instead of + // PropertyEditor widgets, because of + // https://github.com/bergie/create/issues/133. + var newState = (this.model.get('isViewing')) ? 'inactive' : 'candidate'; + this.$entityElements.each(function() { + $(this).createEditable('setState', newState); + }); + }, + + /** + * Accepts or reject editor (PropertyEditor) state changes. + * + * This is what ensures that the app is in control of what happens. + * + * @param from + * The previous state. + * @param to + * The new state. + * @param predicate + * The predicate of the property for which the state change is happening. + * @param context + * The context that is trying to trigger the state change. + * @param callback + * The callback function that should receive the state acceptance result. + */ + acceptEditorStateChange: function(from, to, predicate, context, callback) { + var accept = true; + + // If the app is in view mode, then reject all state changes except for + // those to 'inactive'. + if (this.model.get('isViewing')) { + if (to !== 'inactive') { + accept = false; + } + } + // Handling of edit mode state changes is more granular. + else { + // In general, enforce the states sequence. Disallow going back from a + // "later" state to an "earlier" state, except in explicitly allowed + // cases. + if (_.indexOf(this.states, from) > _.indexOf(this.states, to)) { + accept = false; + // Allow: activating/active -> candidate. + // Necessary to stop editing a property. + if (_.indexOf(this.activeEditorStates, from) !== -1 && to === 'candidate') { + accept = true; + } + // Allow: changed/invalid -> candidate. + // Necessary to stop editing a property when it is changed or invalid. + else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { + accept = true; + } + // Allow: highlighted -> candidate. + // Necessary to stop highlighting a property. + else if (from === 'highlighted' && to === 'candidate') { + accept = true; + } + // Allow: saved -> candidate. + // Necessary when successfully saved a property. + else if (from === 'saved' && to === 'candidate') { + accept = true; + } + // Allow: invalid -> saving. + // Necessary to be able to save a corrected, invalid property. + else if (from === 'invalid' && to === 'saving') { + accept = true; + } + } + + // If it's not against the general principle, then here are more + // disallowed cases to check. + if (accept) { + // Ensure only one editor (field) at a time may be higlighted or active. + if (from === 'candidate' && _.indexOf(this.singleEditorStates, to) !== -1) { + if (this.model.get('highlightedEditor') || this.model.get('activeEditor')) { + accept = false; + } + } + // Reject going from activating/active to candidate because of a + // mouseleave. + else if (_.indexOf(this.activeEditorStates, from) !== -1 && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + } + // When attempting to stop editing a changed/invalid property, ask for + // confirmation. + else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + else { + // Check whether the transition has been confirmed? + if (context && context.confirmed) { + accept = true; + } + // Confirm this transition. + else { + // The callback will be called from the helper function. + this._confirmStopEditing(callback); + return; + } + } + } + } + } + + callback(accept); + }, + + /** + * Asks the user to confirm whether he wants to stop editing via a modal. + * + * @param acceptCallback + * The callback function as passed to acceptEditorStateChange(). This + * callback function will be called with the user's choice. + * + * @see acceptEditorStateChange() + */ + _confirmStopEditing: function(acceptCallback) { + // Only instantiate if there isn't a modal instance visible yet. + if (!this.model.get('activeModal')) { + var that = this; + var modal = new Drupal.edit.views.ModalView({ + model: this.model, + message: Drupal.t('You have unsaved changes'), + buttons: [ + { action: 'discard', classes: 'gray-button', label: Drupal.t('Discard changes') }, + { action: 'save', classes: 'blue-button', label: Drupal.t('Save') } + ], + callback: function(action) { + // The active modal has been removed. + that.model.set('activeModal', null); + if (action === 'discard') { + acceptCallback(true); + } + else { + acceptCallback(false); + var editor = that.model.get('activeEditor'); + editor.options.widget.setState('saving', editor.options.property); + } + } + }); + this.model.set('activeModal', modal); + // The modal will set the activeModal property on the model when rendering + // to prevent multiple modals from being instantiated. + modal.render(); + } + else { + // Reject as there is still an open transition waiting for confirmation. + acceptCallback(false); + } + }, + + /** + * Reacts to editor (PropertyEditor) state changes; tracks global state. + * + * @param from + * The previous state. + * @param to + * The new state. + * @param editor + * The PropertyEditor widget object. + */ + editorStateChange: function(from, to, editor) { + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Get rid of this once that issue is solved. + if (!editor) { + return; + } + else { + editor.stateChange(from, to); + } + + // Keep track of the highlighted editor in the global state. + if (_.indexOf(this.singleEditorStates, to) !== -1 && this.model.get('highlightedEditor') !== editor) { + this.model.set('highlightedEditor', editor); + } + else if (this.model.get('highlightedEditor') === editor && to === 'candidate') { + this.model.set('highlightedEditor', null); + } + + // Keep track of the active editor in the global state. + if (_.indexOf(this.activeEditorStates, to) !== -1 && this.model.get('activeEditor') !== editor) { + this.model.set('activeEditor', editor); + } + else if (this.model.get('activeEditor') === editor && to === 'candidate') { + this.model.set('activeEditor', null); + } + + // Propagate the state change to the decoration and toolbar views. + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Uncomment this once that issue is solved. + // editor.decorationView.stateChange(from, to); + // editor.toolbarView.stateChange(from, to); + }, + + /** + * Decorates an editor (PropertyEditor). + * + * Upon the page load, all appropriate editors are initialized and decorated + * (i.e. even before anything of the editing UI becomes visible; even before + * edit mode is enabled). + * + * @param editor + * The PropertyEditor widget object. + */ + decorateEditor: function(editor) { + // Toolbars are rendered "on-demand" (highlighting or activating). + // They are a sibling element before the editor's DOM element. + editor.toolbarView = new Drupal.edit.views.ToolbarView({ + editor: editor, + $storageWidgetEl: this.$el + }); + + // Decorate the editor's DOM element depending on its state. + editor.decorationView = new Drupal.edit.views.PropertyEditorDecorationView({ + el: editor.element, + editor: editor, + toolbarId: editor.toolbarView.getId() + }); + + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Get rid of this once that issue is solved. + editor.options.widget.element.on('createeditablestatechange', function(event, data) { + editor.decorationView.stateChange(data.previous, data.current); + editor.toolbarView.stateChange(data.previous, data.current); + }); + } + }); + +})(jQuery, _, Backbone, Drupal, VIE); diff --git a/core/modules/edit/js/backbone.drupalform.js b/core/modules/edit/js/backbone.drupalform.js new file mode 100644 index 0000000..cc0e5c3 --- /dev/null +++ b/core/modules/edit/js/backbone.drupalform.js @@ -0,0 +1,157 @@ +/** + * @file + * Backbone.sync implementation for Edit. This is the beating heart. + */ +(function (jQuery, Backbone, Drupal) { + +"use strict"; + +Backbone.defaultSync = Backbone.sync; +Backbone.sync = function(method, model, options) { + if (options.editor.options.editorName === 'form') { + return Backbone.syncDrupalFormWidget(method, model, options); + } + else { + return Backbone.syncDirect(method, model, options); + } +}; + +/** + * Performs syncing for "form" PredicateEditor widgets. + * + * Implemented on top of Form API and the AJAX commands framework. Sets up + * scoped AJAX command closures specifically for a given PredicateEditor widget + * (which contains a pre-existing form). By submitting the form through + * Drupal.ajax and leveraging Drupal.ajax' ability to have scoped (per-instance) + * command implementations, we are able to update the VIE model, re-render the + * form when there are validation errors and ensure no Drupal.ajax memory leaks. + * + * @see Drupal.edit.util.form + */ +Backbone.syncDrupalFormWidget = function(method, model, options) { + if (method === 'update') { + var predicate = options.editor.options.property; + + var $formContainer = options.editor.$formContainer; + var $submit = $formContainer.find('.edit-form-submit'); + var base = $submit.attr('id'); + + // Successfully saved. + Drupal.ajax[base].commands.editFieldFormSaved = function(ajax, response, status) { + Drupal.edit.util.form.unajaxifySaving(jQuery(ajax.element)); + + // Call Backbone.sync's success callback with the rerendered field. + var changedAttributes = {}; + // @todo: POSTPONED_ON(Drupal core, http://drupal.org/node/1784216) + // Once full JSON-LD support in Drupal core lands, we can ensure that the + // models that VIE maintains are properly updated. + changedAttributes[predicate] = 'JSON-LD representation N/A yet.'; + changedAttributes[predicate + '/rendered'] = response.data; + options.success(changedAttributes); + }; + + // Unsuccessfully saved; validation errors. + Drupal.ajax[base].commands.editFieldFormValidationErrors = function(ajax, response, status) { + // Call Backbone.sync's error callback with the validation error messages. + options.error(response.data); + }; + + // The edit_field_form AJAX command is only called upon loading the form for + // the first time, and when there are validation errors in the form; Form + // API then marks which form items have errors. Therefor, we have to replace + // the existing form, unbind the existing Drupal.ajax instance and create a + // new Drupal.ajax instance. + Drupal.ajax[base].commands.editFieldForm = function(ajax, response, status) { + Drupal.edit.util.form.unajaxifySaving(jQuery(ajax.element)); + + Drupal.ajax.prototype.commands.insert(ajax, { + data: response.data, + selector: '#' + $formContainer.attr('id') + ' form' + }); + + // Create a Drupa.ajax instance for the re-rendered ("new") form. + var $newSubmit = $formContainer.find('.edit-form-submit'); + Drupal.edit.util.form.ajaxifySaving({ nocssjs: false }, $newSubmit); + }; + + // Click the form's submit button; the scoped AJAX commands above will + // handle the server's response. + $submit.trigger('click.edit'); + } +}; + +/** +* Performs syncing for "direct" PredicateEditor widgets. + * + * @see Backbone.syncDrupalFormWidget() + * @see Drupal.edit.util.form + */ +Backbone.syncDirect = function(method, model, options) { + if (method === 'update') { + var fillAndSubmitForm = function(value) { + jQuery('#edit_backstage form') + // Fill in the value in any that isn't hidden or a submit button. + .find(':input[type!="hidden"][type!="submit"]:not(select)').val(value).end() + // Submit the form. + .find('.edit-form-submit').trigger('click.edit'); + }; + var entity = options.editor.options.entity; + var predicate = options.editor.options.property; + var value = model.get(predicate); + + // If form doesn't already exist, load it and then submit. + if (jQuery('#edit_backstage form').length === 0) { + var formOptions = { + propertyID: Drupal.edit.util.calcPropertyID(entity, predicate), + $editorElement: options.editor.element, + nocssjs: true + }; + Drupal.edit.util.form.load(formOptions, function(form, ajax) { + // Create a backstage area for storing forms that are hidden from view + // (hence "backstage" — since the editing doesn't happen in the form, it + // happens "directly" in the content, the form is only used for saving). + jQuery(Drupal.theme('editBackstage', { id: 'edit_backstage' })).appendTo('body'); + // Direct forms are stuffed into #edit_backstage, apparently. + jQuery('#edit_backstage').append(form); + // Disable the browser's HTML5 validation; we only care about server- + // side validation. (Not disabling this will actually cause problems + // because browsers don't like to set HTML5 validation errors on hidden + // forms.) + jQuery('#edit_backstage form').attr('novalidate', true); + var $submit = jQuery('#edit_backstage form .edit-form-submit'); + var base = Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); + + // Successfully saved. + Drupal.ajax[base].commands.editFieldFormSaved = function (ajax, response, status) { + Drupal.edit.util.form.unajaxifySaving(jQuery(ajax.element)); + jQuery('#edit_backstage form').remove(); + + options.success(); + }; + + // Unsuccessfully saved; validation errors. + Drupal.ajax[base].commands.editFieldFormValidationErrors = function(ajax, response, status) { + // Call Backbone.sync's error callback with the validation error messages. + options.error(response.data); + }; + + // The editFieldForm AJAX command is only called upon loading the form + // for the first time, and when there are validation errors in the form; + // Form API then marks which form items have errors. This is useful for + // "form" editors, but pointless for "direct" editors: the form itself + // won't be visible at all anyway! Therefor, we ignore the new form and + // we continue to use the existing form. + Drupal.ajax[base].commands.editFieldForm = function(ajax, response, status) { + // no-op + }; + + fillAndSubmitForm(value); + }); + } + else { + fillAndSubmitForm(value); + } + } +}; + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/createjs/editable.js b/core/modules/edit/js/createjs/editable.js new file mode 100644 index 0000000..aac1ed2 --- /dev/null +++ b/core/modules/edit/js/createjs/editable.js @@ -0,0 +1,43 @@ +/** + * @file + * Determines which editor to use based on a class attribute. + */ +(function (jQuery, drupalSettings) { + +"use strict"; + + jQuery.widget('Drupal.createEditable', jQuery.Midgard.midgardEditable, { + _create: function() { + this.vie = this.options.vie; + + this.options.domService = 'edit'; + this.options.predicateSelector = '*'; //'.edit-field.edit-allowed'; + + this.options.editors.direct = { + widget: 'drupalContentEditableWidget', + options: {} + }; + this.options.editors['direct-with-wysiwyg'] = { + widget: drupalSettings.edit.wysiwygEditorWidgetName, + options: {} + }; + this.options.editors.form = { + widget: 'drupalFormWidget', + options: {} + }; + + jQuery.Midgard.midgardEditable.prototype._create.call(this); + }, + + _propertyEditorName: function(data) { + if (jQuery(this.element).hasClass('edit-type-direct')) { + if (jQuery(this.element).hasClass('edit-type-direct-with-wysiwyg')) { + return 'direct-with-wysiwyg'; + } + return 'direct'; + } + return 'form'; + } + }); + +})(jQuery, drupalSettings); diff --git a/core/modules/edit/js/createjs/editingWidgets/drupalcontenteditablewidget.js b/core/modules/edit/js/createjs/editingWidgets/drupalcontenteditablewidget.js new file mode 100644 index 0000000..c773e6e --- /dev/null +++ b/core/modules/edit/js/createjs/editingWidgets/drupalcontenteditablewidget.js @@ -0,0 +1,110 @@ +/** + * @file + * Override of Create.js' default "base" (plain contentEditable) widget. + */ +(function (jQuery, Drupal) { + +"use strict"; + + jQuery.widget('Drupal.drupalContentEditableWidget', jQuery.Create.editWidget, { + + /** + * Implements jQuery UI widget factory's _init() method. + * + * @todo: POSTPONED_ON(Create.js, https://github.com/bergie/create/issues/142) + * Get rid of this once that issue is solved. + */ + _init: function() {}, + + /** + * Implements Create's _initialize() method. + */ + _initialize: function() { + var that = this; + + // Sets the state to 'activated' upon clicking the element. + this.element.on("click.edit", function(event) { + event.stopPropagation(); + event.preventDefault(); + that.options.activated(); + }); + + // Sets the state to 'changed' whenever the content has changed. + var before = jQuery.trim(this.element.text()); + this.element.on('keyup paste', function (event) { + if (that.options.disabled) { + return; + } + var current = jQuery.trim(that.element.text()); + if (before !== current) { + before = current; + that.options.changed(current); + } + }); + }, + + /** + * Makes this PropertyEditor widget react to state changes. + */ + stateChange: function(from, to) { + switch (to) { + case 'inactive': + break; + case 'candidate': + if (from !== 'inactive') { + // Removes the "contenteditable" attribute. + this.disable(); + this._removeValidationErrors(); + this._cleanUp(); + } + break; + case 'highlighted': + break; + case 'activating': + break; + case 'active': + // Sets the "contenteditable" attribute to "true". + this.enable(); + break; + case 'changed': + break; + case 'saving': + this._removeValidationErrors(); + break; + case 'saved': + break; + case 'invalid': + break; + } + }, + + /** + * Removes validation errors' markup changes, if any. + * + * Note: this only needs to happen for type=direct, because for type=direct, + * the property DOM element itself is modified; this is not the case for + * type=form. + */ + _removeValidationErrors: function() { + this.element + .removeClass('edit-validation-error') + .next('.edit-validation-errors').remove(); + }, + + /** + * Cleans up after the widget has been saved. + * + * Note: this is where the Create.Storage and accompanying Backbone.sync + * abstractions "leak" implementation details. That is only the case because + * we have to use Drupal's Form API as a transport mechanism. It is + * unfortunately a stateful transport mechanism, and that's why we have to + * clean it up here. This clean-up is only necessary when canceling the + * editing of a property after having attempted to save at least once. + */ + _cleanUp: function() { + Drupal.edit.util.form.unajaxifySaving(jQuery('#edit_backstage form .edit-form-submit')); + jQuery('#edit_backstage form').remove(); + } + }); + +})(jQuery, Drupal); diff --git a/core/modules/edit/js/createjs/editingWidgets/formwidget.js b/core/modules/edit/js/createjs/editingWidgets/formwidget.js new file mode 100644 index 0000000..9a5153d --- /dev/null +++ b/core/modules/edit/js/createjs/editingWidgets/formwidget.js @@ -0,0 +1,149 @@ +/** + * @file + * Form-based Create.js widget for structured content in Drupal. + */ +(function ($, Drupal) { + +"use strict"; + + $.widget('Drupal.drupalFormWidget', $.Create.editWidget, { + + id: null, + $formContainer: null, + + /** + * Implements jQuery UI widget factory's _init() method. + * + * @todo: POSTPONED_ON(Create.js, https://github.com/bergie/create/issues/142) + * Get rid of this once that issue is solved. + */ + _init: function() {}, + + /** + * Implements Create's _initialize() method. + */ + _initialize: function() { + // Sets the state to 'activating' upon clicking the element. + var that = this; + this.element.on("click.edit", function(event) { + event.stopPropagation(); + event.preventDefault(); + that.options.activating(); + }); + }, + + /** + * Makes this PropertyEditor widget react to state changes. + */ + stateChange: function(from, to) { + switch (to) { + case 'inactive': + break; + case 'candidate': + if (from !== 'inactive') { + this.disable(); + } + break; + case 'highlighted': + break; + case 'activating': + this.enable(); + break; + case 'active': + break; + case 'changed': + break; + case 'saving': + break; + case 'saved': + break; + case 'invalid': + break; + } + }, + + /** + * Enables the widget. + */ + enable: function () { + var $editorElement = $(this.options.widget.element); + var propertyID = Drupal.edit.util.calcPropertyID(this.options.entity, this.options.property); + + // Generate a DOM-compatible ID for the form container DOM element. + this.id = 'edit-form-for-' + propertyID.replace(/\//g, '_'); + + // Render form container. + this.$formContainer = $(Drupal.theme('editFormContainer', { + id: this.id, + loadingMsg: Drupal.t('Loading…')} + )); + this.$formContainer + .find('.edit-form') + .addClass('edit-editable edit-highlighted edit-editing') + .css('background-color', $editorElement.css('background-color')); + + // Insert form container in DOM. + if ($editorElement.css('display') === 'inline') { + // @todo: POSTPONED_ON(Drupal core, title/author/date as Entity Properties) + // This is untested in Drupal 8, because in Drupal 8 we don't yet + // have the ability to edit the node title/author/date, because they + // haven't been converted into Entity Properties yet, and they're the + // only examples in core of "display: inline" properties. + this.$formContainer.prependTo($editorElement.offsetParent()); + + var pos = $editorElement.position(); + this.$formContainer.css('left', pos.left).css('top', pos.top); + } + else { + this.$formContainer.insertBefore($editorElement); + } + + // Load form, insert it into the form container and attach event handlers. + var widget = this; + var formOptions = { + propertyID: propertyID, + $editorElement: $editorElement, + nocssjs: false + }; + Drupal.edit.util.form.load(formOptions, function(form, ajax) { + Drupal.ajax.prototype.commands.insert(ajax, { + data: form, + selector: '#' + widget.id + ' .placeholder' + }); + + var $submit = widget.$formContainer.find('.edit-form-submit'); + Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); + widget.$formContainer + .on('formUpdated.edit', ':input', function () { + // Sets the state to 'changed'. + widget.options.changed(); + }) + .on('keypress.edit', 'input', function (event) { + if (event.keyCode === 13) { + return false; + } + }); + + // Sets the state to 'activated'. + widget.options.activated(); + }); + }, + + /** + * Disables the widget. + */ + disable: function () { + if (this.$formContainer === null) { + return; + } + + Drupal.edit.util.form.unajaxifySaving(this.$formContainer.find('.edit-form-submit')); + this.$formContainer + .off('change.edit', ':input') + .off('keypress.edit', 'input') + .remove(); + this.$formContainer = null; + } + }); + +})(jQuery, Drupal); diff --git a/core/modules/edit/js/createjs/storage.js b/core/modules/edit/js/createjs/storage.js new file mode 100644 index 0000000..580ff82 --- /dev/null +++ b/core/modules/edit/js/createjs/storage.js @@ -0,0 +1,11 @@ +/** + * @file + * Subclasses jQuery.Midgard.midgardStorage to have consistent namespaces. + */ +(function(jQuery) { + +"use strict"; + + jQuery.widget('Drupal.createStorage', jQuery.Midgard.midgardStorage, {}); + +})(jQuery); diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js new file mode 100644 index 0000000..2c42068 --- /dev/null +++ b/core/modules/edit/js/edit.js @@ -0,0 +1,55 @@ +/** + * @file + * Behaviors for Edit, including the one that initializes Edit's EditAppView. + */ +(function ($, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; + +Drupal.behaviors.editDiscoverEditables = { + attach: function(context) { + // @todo BLOCKED_ON(VIE.js, how to let VIE know that some content was removed and how to scan new content for VIE entities, to make them editable?) + // Also see ToolbarView.save(). + // We need to separate the discovery of editables if we want updated + // or new content (added by code other than Edit) to be detected + // automatically. Once we implement this, we'll be able to get rid of all + // calls to Drupal.edit.domService.findSubjectElements() :) + } +}; + +/** + * Attach toggling behavior and in-place editing. + */ +Drupal.behaviors.edit = { + attach: function(context) { + $('#edit_view-edit-toggles').once('edit-init', Drupal.edit.init); + + // As soon as there is at least one editable field, show the Edit tab in the + // toolbar. + if ($(context).find('.edit-field.edit-allowed').length) { + $('.toolbar .icon-edit.edit-nothing-editable-hidden').removeClass('edit-nothing-editable-hidden'); + } + } +}; + +Drupal.edit.init = function() { + // Instantiate EditAppView, which is the controller of it all. EditAppModel + // instance tracks global state (viewing/editing in-place). + var appModel = new Drupal.edit.models.EditAppModel(); + var app = new Drupal.edit.EditAppView({ + el: $('body'), + model: appModel + }); + + // Instantiate EditRouter. + var editRouter = new Drupal.edit.routers.EditRouter({ + appModel: appModel + }); + + // Start Backbone's history/route handling. + Backbone.history.start(); +}; + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/models/edit-app-model.js b/core/modules/edit/js/models/edit-app-model.js new file mode 100644 index 0000000..b6ff36f --- /dev/null +++ b/core/modules/edit/js/models/edit-app-model.js @@ -0,0 +1,22 @@ +/** + * @file + * A Backbone Model that models the current Edit application state. + */ +(function(Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.models = Drupal.edit.models || {}; +Drupal.edit.models.EditAppModel = Backbone.Model.extend({ + defaults: { + // We always begin in view mode. + isViewing: true, + highlightedEditor: null, + activeEditor: null, + // Reference to a ModalView-instance if a transition requires confirmation. + activeModal: null + } +}); + +})(Backbone, Drupal); diff --git a/core/modules/edit/js/routers/edit-router.js b/core/modules/edit/js/routers/edit-router.js new file mode 100644 index 0000000..4d7b196 --- /dev/null +++ b/core/modules/edit/js/routers/edit-router.js @@ -0,0 +1,54 @@ +/** + * @file + * A Backbone Router enabling URLs to make the user enter edit mode directly. + */ +(function(Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.routers = {}; +Drupal.edit.routers.EditRouter = Backbone.Router.extend({ + + appModel: null, + + routes: { + "quick-edit": "edit", + "view": "view", + "": "view" + }, + + initialize: function(options) { + this.appModel = options.appModel; + }, + + edit: function() { + this.appModel.set('isViewing', false); + }, + + view: function(query, page) { + var that = this; + + // If there's an active editor, attempt to set its state to 'candidate', and + // then act according to the user's choice. + var activeEditor = this.appModel.get('activeEditor'); + if (activeEditor) { + var editableEntity = activeEditor.options.widget; + var predicate = activeEditor.options.property; + editableEntity.setState('candidate', predicate, { reason: 'menu' }, function(accepted) { + if (accepted) { + that.appModel.set('isViewing', true); + } + else { + that.navigate('#quick-edit'); + } + }); + } + // Otherwise, we can switch to view mode directly. + else { + that.appModel.set('isViewing', true); + } + } +}); + +})(Backbone, Drupal); diff --git a/core/modules/edit/js/theme.js b/core/modules/edit/js/theme.js new file mode 100644 index 0000000..f92c308 --- /dev/null +++ b/core/modules/edit/js/theme.js @@ -0,0 +1,156 @@ +/** + * @file + * Provides overridable theme functions for all of Edit's client-side HTML. + */ +(function($, Drupal) { + +"use strict"; + +/** + * Theme function for the overlay of the Edit module. + * + * @param settings + * An object with the following keys: + * - None. + * @return + * The corresponding HTML. + */ +Drupal.theme.editOverlay = function(settings) { + var html = ''; + html += '
'; + return html; +}; + +/** + * Theme function for a "backstage" for the Edit module. + * + * @param settings + * An object with the following keys: + * - id: the id to apply to the backstage. + * @return + * The corresponding HTML. + */ +Drupal.theme.editBackstage = function(settings) { + var html = ''; + html += '
'; + return html; +}; + +/** + * Theme function for a modal of the Edit module. + * + * @param settings + * An object with the following keys: + * - None. + * @return + * The corresponding HTML. + */ +Drupal.theme.editModal = function(settings) { + var classes = 'edit-animate-slow edit-animate-invisible edit-animate-delay-veryfast'; + var html = ''; + html += '
'; + html += '

'; + html += '
'; + html += '
'; + return html; +}; + +/** + * Theme function for a toolbar container of the Edit module. + * + * @param settings + * An object with the following keys: + * - id: the id to apply to the toolbar container. + * @return + * The corresponding HTML. + */ +Drupal.theme.editToolbarContainer = function(settings) { + var html = ''; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + return html; +}; + +/** + * Theme function for a toolbar toolgroup of the Edit module. + * + * @param settings + * An object with the following keys: + * - classes: the class of the toolgroup. + * - buttons: @see Drupal.theme.prototype.editButtons(). + * @return + * The corresponding HTML. + */ +Drupal.theme.editToolgroup = function(settings) { + var classes = 'edit-toolgroup edit-animate-slow edit-animate-invisible edit-animate-delay-veryfast'; + var html = ''; + html += '
'; + html += Drupal.theme('editButtons', { buttons: settings.buttons }); + html += '
'; + return html; +}; + +/** + * Theme function for buttons of the Edit module. + * + * Can be used for the buttons both in the toolbar toolgroups and in the modal. + * + * @param settings + * An object with the following keys: + * - buttons: an array of objects with the following keys: + * - url: the URL the button should point to. + * - classes: the classes of the button. + * - label: the label of the button. + * - hasButtonRole: whether this button should have its "role" attribute set + * to "button". + * - action: sets a data-edit-modal-action attribute. + * @return + * The corresponding HTML. + */ +Drupal.theme.editButtons = function(settings) { + var html = ''; + for (var i = 0; i < settings.buttons.length; i++) { + var button = settings.buttons[i]; + if (!button.hasOwnProperty('url')) { + button.url = ''; + } + if (!button.hasOwnProperty('hasButtonRole')) { + button.hasButtonRole = true; + } + + html += ''; + html += '
'; + html += '
'; + html += settings.loadingMsg; + html += '
'; + html += '
'; + html += '
'; + return html; +}; + +})(jQuery, Drupal); diff --git a/core/modules/edit/js/util.js b/core/modules/edit/js/util.js new file mode 100644 index 0000000..8ed9a2b --- /dev/null +++ b/core/modules/edit/js/util.js @@ -0,0 +1,142 @@ +/** + * @file + * Provides utility functions for Edit. + */ +(function($, Drupal, drupalSettings) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.util = Drupal.edit.util || {}; + +Drupal.edit.util.constants = {}; +Drupal.edit.util.constants.transitionEnd = "transitionEnd.edit webkitTransitionEnd.edit transitionend.edit msTransitionEnd.edit oTransitionEnd.edit"; + +Drupal.edit.util.calcPropertyID = function(entity, predicate) { + return entity.getSubjectUri() + '/' + predicate; +}; + +Drupal.edit.util.buildUrl = function(id, urlFormat) { + var parts = id.split('/'); + return Drupal.formatString(decodeURIComponent(urlFormat), { + '!entity_type': parts[0], + '!id' : parts[1], + '!field_name' : parts[2], + '!langcode' : parts[3], + '!view_mode' : parts[4] + }); +}; + +/** + * Loads rerendered processed text for a given property. + * + * Leverages Drupal.ajax' ability to have scoped (per-instance) command + * implementations to be able to call a callback. + * + * @param options + * An object with the following keys: + * - $editorElement (required): the PredicateEditor DOM element. + * - propertyID (required): the property ID that uniquely identifies the + * property for which this form will be loaded. + * - callback (required: A callback function that will receive the rerendered + * processed text. + */ +Drupal.edit.util.loadRerenderedProcessedText = function(options) { + // Create a Drupal.ajax instance to load the form. + Drupal.ajax[options.propertyID] = new Drupal.ajax(options.propertyID, options.$editorElement, { + url: Drupal.edit.util.buildUrl(options.propertyID, drupalSettings.edit.rerenderProcessedTextURL), + event: 'edit-internal.edit', + submit: { nocssjs : true }, + progress: { type : null } // No progress indicator. + }); + // Implement a scoped editFieldRenderedWithoutTransformationFilters AJAX + // command: calls the callback. + Drupal.ajax[options.propertyID].commands.editFieldRenderedWithoutTransformationFilters = function(ajax, response, status) { + options.callback(response.data); + // Delete the Drupal.ajax instance that called this very function. + delete Drupal.ajax[options.propertyID]; + options.$editorElement.off('edit-internal.edit'); + }; + // This will ensure our scoped editFieldRenderedWithoutTransformationFilters + // AJAX command gets called. + options.$editorElement.trigger('edit-internal.edit'); +}; + +Drupal.edit.util.form = { + /** + * Loads a form, calls a callback to inserts. + * + * Leverages Drupal.ajax' ability to have scoped (per-instance) command + * implementations to be able to call a callback. + * + * @param options + * An object with the following keys: + * - $editorElement (required): the PredicateEditor DOM element. + * - propertyID (required): the property ID that uniquely identifies the + * property for which this form will be loaded. + * - nocssjs (required): boolean indicating whether no CSS and JS should be + * returned (necessary when the form is invisible to the user). + * @param callback + * A callback function that will receive the form to be inserted, as well as + * the ajax object, necessary if the callback wants to perform other AJAX + * commands. + */ + load: function(options, callback) { + // Create a Drupal.ajax instance to load the form. + Drupal.ajax[options.propertyID] = new Drupal.ajax(options.propertyID, options.$editorElement, { + url: Drupal.edit.util.buildUrl(options.propertyID, drupalSettings.edit.fieldFormURL), + event: 'edit-internal.edit', + submit: { nocssjs : options.nocssjs }, + progress: { type : null } // No progress indicator. + }); + // Implement a scoped editFieldForm AJAX command: calls the callback. + Drupal.ajax[options.propertyID].commands.editFieldForm = function(ajax, response, status) { + callback(response.data, ajax); + // Delete the Drupal.ajax instance that called this very function. + delete Drupal.ajax[options.propertyID]; + options.$editorElement.off('edit-internal.edit'); + }; + // This will ensure our scoped editFieldForm AJAX command gets called. + options.$editorElement.trigger('edit-internal.edit'); + }, + + /** + * Creates a Drupal.ajax instance that is used to save a form. + * + * @param options + * An object with the following keys: + * - nocssjs (required): boolean indicating whether no CSS and JS should be + * returned (necessary when the form is invisible to the user). + * + * @return + * The key of the Drupal.ajax instance. + */ + ajaxifySaving: function(options, $submit) { + // Re-wire the form to handle submit. + var element_settings = { + url: $submit.closest('form').attr('action'), + setClick: true, + event: 'click.edit', + progress: { type:'throbber' }, + submit: { nocssjs : options.nocssjs } + }; + var base = $submit.attr('id'); + + Drupal.ajax[base] = new Drupal.ajax(base, $submit[0], element_settings); + + return base; + }, + + /** + * Cleans up the Drupal.ajax instance that is used to save the form. + * + * @param $submit + * The jQuery-wrapped submit DOM element that should be unajaxified. + */ + unajaxifySaving: function($submit) { + delete Drupal.ajax[$submit.attr('id')]; + $submit.off('click.edit'); + } +}; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/edit/js/viejs/EditService.js b/core/modules/edit/js/viejs/EditService.js new file mode 100644 index 0000000..5545463 --- /dev/null +++ b/core/modules/edit/js/viejs/EditService.js @@ -0,0 +1,201 @@ +/** + * @file + * VIE DOM parsing service for Edit. + */ +(function(jQuery, _, VIE, Drupal, drupalSettings) { + +"use strict"; + + VIE.prototype.EditService = function (options) { + var defaults = { + name: 'edit', + subjectSelector: '.edit-field.edit-allowed' + }; + this.options = _.extend({}, defaults, options); + + this.views = []; + this.vie = null; + this.name = this.options.name; + }; + + VIE.prototype.EditService.prototype = { + load: function (loadable) { + var correct = loadable instanceof this.vie.Loadable; + if (!correct) { + throw new Error('Invalid Loadable passed'); + } + + var element; + if (!loadable.options.element) { + if (typeof document === 'undefined') { + return loadable.resolve([]); + } else { + element = drupalSettings.edit.context; + } + } else { + element = loadable.options.element; + } + + var entities = this.readEntities(element); + loadable.resolve(entities); + }, + + // The edit-id data attribute contains the full identifier of + // each entity element in the format + // `::::`. + _getID: function (element) { + var id = jQuery(element).attr('data-edit-id'); + if (!id) { + id = jQuery(element).closest('[data-edit-id]').attr('data-edit-id'); + } + return id; + }, + + // Returns the "URI" of an entity of an element in format + // `/`. + getElementSubject: function (element) { + return this._getID(element).split(':').slice(0, 2).join('/'); + }, + + // Returns the field name for an element in format + // `//`. + // (Slashes instead of colons because the field name is no namespace.) + getElementPredicate: function (element) { + if (!this._getID(element)) { + throw new Error('Could not find predicate for element'); + } + return this._getID(element).split(':').slice(2, 5).join('/'); + }, + + getElementType: function (element) { + return this._getID(element).split(':').slice(0, 1)[0]; + }, + + // Reads all editable entities (currently each Drupal field is considered an + // entity, in the future Drupal entities should be mapped to VIE entities) + // from DOM and returns the VIE enties it found. + readEntities: function (element) { + var service = this; + var entities = []; + var entityElements = jQuery(this.options.subjectSelector, element); + entityElements = entityElements.add(jQuery(element).filter(this.options.subjectSelector)); + entityElements.each(function () { + var entity = service._readEntity(jQuery(this)); + if (entity) { + entities.push(entity); + } + }); + return entities; + }, + + // Returns a filled VIE Entity instance for a DOM element. The Entity + // is also registered in the VIE entities collection. + _readEntity: function (element) { + var subject = this.getElementSubject(element); + var type = this.getElementType(element); + var entity = this._readEntityPredicates(subject, element, false); + if (jQuery.isEmptyObject(entity)) { + return null; + } + entity['@subject'] = subject; + if (type) { + entity['@type'] = this._registerType(type, element); + } + + // Register with VIE + return this._registerEntity(entity); + }, + + _registerEntity: function (entityData) { + var entityInstance = new this.vie.Entity(entityData); + return this.vie.entities.addOrUpdate(entityInstance, { + updateOptions: { + silent: true + } + }); + }, + + _registerType: function (typeId, element) { + typeId = ''; + var type = this.vie.types.get(typeId); + if (!type) { + this.vie.types.add(typeId, []); + type = this.vie.types.get(typeId); + } + + var predicate = this.getElementPredicate(element); + if (type.attributes.get(predicate)) { + return type; + } + + var label = element.data('edit-field-label'); + var range = 'Form'; + if (element.hasClass('edit-type-direct')) { + range = 'Direct'; + } + if (element.hasClass('edit-type-direct-with-wysiwyg')) { + range = 'Wysiwyg'; + } + type.attributes.add(predicate, [range], 0, 1, { + label: element.data('edit-field-label') + }); + + return type; + }, + + _readEntityPredicates: function (subject, element, emptyValues) { + var entityPredicates = {}; + var service = this; + this.findPredicateElements(subject, element, true).each(function () { + var predicateElement = jQuery(this); + var predicate = service.getElementPredicate(predicateElement); + if (!predicate) { + return; + } + var value = service._readElementValue(predicateElement); + if (value === null && !emptyValues) { + return; + } + + entityPredicates[predicate] = value; + }); + return entityPredicates; + }, + + _readElementValue: function (element) { + return jQuery.trim(element.html()); + }, + + // Subject elements are the DOM elements containing a single or multiple + // editable fields. + findSubjectElements: function (element) { + if (!element) { + element = drupalSettings.edit.context; + } + return jQuery(this.options.subjectSelector, element); + }, + + // Predicate Elements are the actual DOM elements that users will be able + // to edit. + findPredicateElements: function (subject, element, allowNestedPredicates, stop) { + var predicates = jQuery(); + + // Form-type predicates + predicates = predicates.add(element.filter('.edit-type-form')); + + // Direct-type predicates + var direct = element.filter('.edit-type-direct'); + predicates = predicates.add(direct.find('.field-item')); + + if (!predicates.length && !stop) { + var parentElement = element.parent(this.options.subjectSelector); + if (parentElement.length) { + return this.findPredicateElements(subject, parentElement, allowNestedPredicates, true); + } + } + + return predicates; + } + }; + +})(jQuery, _, VIE, Drupal, drupalSettings); diff --git a/core/modules/edit/js/views/menu-view.js b/core/modules/edit/js/views/menu-view.js new file mode 100644 index 0000000..834f743 --- /dev/null +++ b/core/modules/edit/js/views/menu-view.js @@ -0,0 +1,42 @@ +/** + * @file + * A Backbone View that provides the app-level interactive menu. + */ +(function($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.MenuView = Backbone.View.extend({ + + /** + * Implements Backbone Views' initialize() function. + */ + initialize: function() { + _.bindAll(this, 'stateChange'); + this.model.on('change:isViewing', this.stateChange); + + // We have to call stateChange() here, because URL fragments are not passed + // the server, thus the wrong anchor may be marked as active. + this.stateChange(); + }, + + /** + * Listens to app state changes. + */ + stateChange: function() { + // Unmark whichever one is currently marked as active. + this.$('a.edit_view-edit-toggle') + .removeClass('active') + .parent().removeClass('active'); + + // Mark the correct one as active. + var activeAnchor = this.model.get('isViewing') ? 'view' : 'edit'; + this.$('a.edit_view-edit-toggle.edit-' + activeAnchor) + .addClass('active') + .parent().addClass('active'); + } +}); + +})(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/js/views/modal-view.js b/core/modules/edit/js/views/modal-view.js new file mode 100644 index 0000000..c5d91ab --- /dev/null +++ b/core/modules/edit/js/views/modal-view.js @@ -0,0 +1,108 @@ +/** + * @file + * A Backbone View that provides an interactive modal. + */ +(function($, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.ModalView = Backbone.View.extend({ + + message: null, + buttons: null, + callback: null, + $elementsToHide: null, + + events: { + 'click a[role=button]': 'onButtonClick' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - message: a message to show in the modal. + * - buttons: a set of buttons with 'action's defined, ready to be passed to + * Drupal.theme.editButtons(). + * - callback: a callback that will receive the 'action' of the clicked + * button. + * + * @see Drupal.theme.editModal() + * @see Drupal.theme.editButtons() + */ + initialize: function(options) { + this.message = options.message; + this.buttons = options.buttons; + this.callback = options.callback; + }, + + /** + * Implements Backbone Views' render() function. + */ + render: function() { + // Step 1: move certain UI elements below the overlay. + var editor = this.model.get('activeEditor'); + this.$elementsToHide = $([]) + .add((editor.element.hasClass('edit-belowoverlay')) ? null : editor.element) + .add(editor.toolbarView.$el) + .add((editor.options.editorName === 'form') + ? editor.$formContainer + : editor.element.next('.edit-validation-errors') + ); + this.$elementsToHide.addClass('edit-belowoverlay'); + + // Step 2: the modal. When the user makes a choice, the UI elements that + // were moved below the overlay will be restored, and the callback will be + // called. + this.setElement(Drupal.theme('editModal', {})); + this.$el.appendTo('body'); + // Template. + this.$('.main p').text(this.message); + var $actions = $(Drupal.theme('editButtons', { 'buttons' : this.buttons})); + this.$('.actions').append($actions); + + // Step 3; show the modal with an animation. + var that = this; + setTimeout(function() { + that.$el.removeClass('edit-animate-invisible'); + }, 0); + }, + + /** + * When the user clicks on any of the buttons, the modal should be removed + * and the result should be passed to the callback. + * + * @param event + */ + onButtonClick: function(event) { + event.stopPropagation(); + event.preventDefault(); + + // Remove after animation. + var that = this; + this.$el + .addClass('edit-animate-invisible') + .on(Drupal.edit.util.constants.transitionEnd, function(e) { + that.remove(); + }); + + var action = $(event.target).attr('data-edit-modal-action'); + return this.callback(action); + }, + + /** + * Overrides Backbone Views' remove() function. + */ + remove: function() { + // Move the moved UI elements on top of the overlay again. + this.$elementsToHide.removeClass('edit-belowoverlay'); + + // Remove the modal itself. + this.$el.remove(); + } +}); + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/views/overlay-view.js b/core/modules/edit/js/views/overlay-view.js new file mode 100644 index 0000000..e357014 --- /dev/null +++ b/core/modules/edit/js/views/overlay-view.js @@ -0,0 +1,82 @@ +/** + * @file + * A Backbone View that provides the app-level overlay. + * + * The overlay sits on top of the existing content, the properties that are + * candidates for editing sit on top of the overlay. + */ +(function ($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.OverlayView = Backbone.View.extend({ + + events: { + 'click': 'onClick' + }, + + /** + * Implements Backbone Views' initialize() function. + */ + initialize: function(options) { + _.bindAll(this, 'stateChange'); + this.model.on('change:isViewing', this.stateChange); + }, + + /** + * Listens to app state changes. + */ + stateChange: function() { + if (this.model.get('isViewing')) { + this.remove(); + return; + } + this.render(); + }, + + /** + * Equates clicks anywhere on the overlay to clicking the active editor's (if + * any) "close" button. + * + * @param event + */ + onClick: function(event) { + event.preventDefault(); + var activeEditor = this.model.get('activeEditor'); + if (activeEditor) { + var editableEntity = activeEditor.options.widget; + var predicate = activeEditor.options.property; + editableEntity.setState('candidate', predicate, { reason: 'overlay' }); + } + }, + + /** + * Inserts the overlay element and appends it to the body. + */ + render: function() { + this.setElement( + $(Drupal.theme('editOverlay', {})) + .appendTo('body') + .addClass('edit-animate-slow edit-animate-invisible') + ); + // Animations + this.$el.css('top', $('#navbar').outerHeight()); + this.$el.removeClass('edit-animate-invisible'); + }, + + /** + * Remove the overlay element. + */ + remove: function() { + var that = this; + this.$el + .addClass('edit-animate-invisible') + .on(Drupal.edit.util.constants.transitionEnd, function (event) { + that.$el.remove(); + }); + } +}); + +})(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/js/views/propertyeditordecoration-view.js b/core/modules/edit/js/views/propertyeditordecoration-view.js new file mode 100644 index 0000000..7d8a27c --- /dev/null +++ b/core/modules/edit/js/views/propertyeditordecoration-view.js @@ -0,0 +1,322 @@ +/** + * @file + * A Backbone View that decorates a Property Editor widget. + * + * It listens to state changes of the property editor. + */ +(function($, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({ + + editor: null, + entity: null, + predicate : null, + editorName: null, + toolbarId: null, + + _widthAttributeIsEmpty: null, + + events: { + 'mouseenter.edit' : 'onMouseEnter', + 'mouseleave.edit' : 'onMouseLeave' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - editor: the editor object with an 'options' object that has these keys: + * * entity: the VIE entity for the property. + * * property: the predicate of the property. + * * editorName: the editor name: 'form', 'direct' or + * 'direct-with-wysiwyg'. + * * widget: the parent EditableeEntity widget. + * - toolbarId: the ID attribute of the toolbar as rendered in the DOM. + */ + initialize: function(options) { + this.editor = options.editor; + this.toolbarId = options.toolbarId; + + this.entity = this.editor.options.entity; + this.predicate = this.editor.options.property; + this.editorName = this.editor.options.editorName; + + this.$el.css('background-color', this._getBgColor(this.$el)); + }, + + /** + * Listens to editor state changes. + */ + stateChange: function(from, to) { + switch (to) { + case 'inactive': + if (from !== null) { + this.undecorate(); + } + break; + case 'candidate': + this.decorate(); + if (from !== 'inactive') { + this.stopHighlight(); + if (from !== 'highlighted') { + this.stopEdit(this.editorName); + } + } + break; + case 'highlighted': + this.startHighlight(); + break; + case 'activating': + // NOTE: this step only exists for the 'form' editor! It is skipped by + // the 'direct' and 'direct-with-wysiwyg' editors, because no loading is + // necessary. + this.prepareEdit(this.editorName); + break; + case 'active': + if (this.editorName !== 'form') { + this.prepareEdit(this.editorName); + } + this.startEdit(this.editorName); + break; + case 'changed': + break; + case 'saving': + break; + case 'saved': + break; + case 'invalid': + break; + } + }, + + /** + * Starts hover: transition to 'highlight' state. + * + * @param event + */ + onMouseEnter: function(event) { + var that = this; + this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { + var editableEntity = that.editor.options.widget; + editableEntity.setState('highlighted', that.predicate); + event.stopPropagation(); + }); + }, + + /** + * Stops hover: back to 'candidate' state. + * + * @param event + */ + onMouseLeave: function(event) { + var that = this; + this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { + var editableEntity = that.editor.options.widget; + editableEntity.setState('candidate', that.predicate, { reason: 'mouseleave' }); + event.stopPropagation(); + }); + }, + + decorate: function () { + this.$el.addClass('edit-animate-fast edit-candidate edit-editable'); + }, + + undecorate: function () { + this.$el + .removeClass('edit-candidate edit-editable edit-highlighted edit-editing edit-belowoverlay'); + }, + + startHighlight: function () { + // Animations. + var that = this; + setTimeout(function() { + that.$el.addClass('edit-highlighted'); + }, 0); + }, + + stopHighlight: function() { + this.$el + .removeClass('edit-highlighted'); + }, + + prepareEdit: function(editorName) { + this.$el.addClass('edit-editing'); + + // While editing, don't show *any* other editors. + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Revisit this. + $('.edit-candidate').not('.edit-editing').removeClass('edit-editable'); + + if (editorName === 'form') { + this.$el.addClass('edit-belowoverlay'); + } + }, + + startEdit: function(editorName) { + if (editorName !== 'form') { + this._pad(); + } + }, + + stopEdit: function(editorName) { + this.$el.removeClass('edit-highlighted edit-editing'); + + // Make the other editors show up again. + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Revisit this. + $('.edit-candidate').addClass('edit-editable'); + + if (editorName === 'form') { + this.$el.removeClass('edit-belowoverlay'); + } + else { + this._unpad(); + } + }, + + _pad: function () { + var self = this; + + // Add 5px padding for readability. This means we'll freeze the current + // width and *then* add 5px padding, hence ensuring the padding is added "on + // the outside". + // 1) Freeze the width (if it's not already set); don't use animations. + if (this.$el[0].style.width === "") { + this._widthAttributeIsEmpty = true; + this.$el + .addClass('edit-animate-disable-width') + .css('width', this.$el.width()); + } + + // 2) Add padding; use animations. + var posProp = this._getPositionProperties(this.$el); + setTimeout(function() { + // Re-enable width animations (padding changes affect width too!). + self.$el.removeClass('edit-animate-disable-width'); + + // Pad the editable. + self.$el + .css({ + 'position': 'relative', + 'top': posProp.top - 5 + 'px', + 'left': posProp.left - 5 + 'px', + 'padding-top' : posProp['padding-top'] + 5 + 'px', + 'padding-left' : posProp['padding-left'] + 5 + 'px', + 'padding-right' : posProp['padding-right'] + 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] + 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] - 10 + 'px' + }); + }, 0); + }, + + _unpad: function () { + var self = this; + + // 1) Set the empty width again. + if (this._widthAttributeIsEmpty) { + this.$el + .addClass('edit-animate-disable-width') + .css('width', ''); + } + + // 2) Remove padding; use animations (these will run simultaneously with) + // the fading out of the toolbar as its gets removed). + var posProp = this._getPositionProperties(this.$el); + setTimeout(function() { + // Re-enable width animations (padding changes affect width too!). + self.$el.removeClass('edit-animate-disable-width'); + + // Unpad the editable. + self.$el + .css({ + 'position': 'relative', + 'top': posProp.top + 5 + 'px', + 'left': posProp.left + 5 + 'px', + 'padding-top' : posProp['padding-top'] - 5 + 'px', + 'padding-left' : posProp['padding-left'] - 5 + 'px', + 'padding-right' : posProp['padding-right'] - 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] - 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] + 10 + 'px' + }); + }, 0); + }, + + /** + * Gets the background color of an element (or the inherited one). + * + * @param $e + * A DOM element. + */ + _getBgColor: function($e) { + var c; + + if ($e === null || $e[0].nodeName === 'HTML') { + // Fallback to white. + return 'rgb(255, 255, 255)'; + } + c = $e.css('background-color'); + // TRICKY: edge case for Firefox' "transparent" here; this is a + // browser bug: https://bugzilla.mozilla.org/show_bug.cgi?id=635724 + if (c === 'rgba(0, 0, 0, 0)' || c === 'transparent') { + return this._getBgColor($e.parent()); + } + return c; + }, + + /** + * Gets the top and left properties of an element and convert extraneous + * values and information into numbers ready for subtraction. + * + * @param $e + * A DOM element. + */ + _getPositionProperties: function($e) { + var p, + r = {}, + props = [ + 'top', 'left', 'bottom', 'right', + 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', + 'margin-bottom' + ]; + + var propCount = props.length; + for (var i = 0; i < propCount; i++) { + p = props[i]; + r[p] = parseInt(this._replaceBlankPosition($e.css(p)), 10); + } + return r; + }, + + /** + * Replaces blank or 'auto' CSS "position: " values with "0px". + * + * @param pos + * The value for a CSS position declaration. + */ + _replaceBlankPosition: function(pos) { + if (pos === 'auto' || !pos) { + pos = '0px'; + } + return pos; + }, + + /** + * Ignores hovering to/from the given closest element, but as soon as a hover + * occurs to/from *another* element, then call the given callback. + */ + _ignoreHoveringVia: function(event, closest, callback) { + if ($(event.relatedTarget).closest(closest).length > 0) { + event.stopPropagation(); + } + else { + callback(); + } + } +}); + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/views/toolbar-view.js b/core/modules/edit/js/views/toolbar-view.js new file mode 100644 index 0000000..4b8235c --- /dev/null +++ b/core/modules/edit/js/views/toolbar-view.js @@ -0,0 +1,445 @@ +/** + * @file + * A Backbone View that provides an interactive toolbar (1 per property editor). + * + * It listens to state changes of the property editor. It also triggers state + * changes in response to user interactions with the toolbar, including saving. + */ +(function ($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.ToolbarView = Backbone.View.extend({ + + editor: null, + $storageWidgetEl: null, + + entity: null, + predicate : null, + editorName: null, + + _id: null, + + events: { + 'click.edit a.label': 'onClickInfoLabel', + 'mouseleave.edit': 'onMouseLeave', + 'click.edit a.field-save': 'onClickSave', + 'click.edit a.field-close': 'onClickClose' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - editor: the editor object with an 'options' object that has these keys: + * * entity: the VIE entity for the property. + * * property: the predicate of the property. + * * editorName: the editor name: 'form', 'direct' or + * 'direct-with-wysiwyg'. + * * element: the jQuery-wrapped editor DOM element + * - $storageWidgetEl: the DOM element on which the Create Storage widget is + * initialized. + */ + initialize: function(options) { + this.editor = options.editor; + this.$storageWidgetEl = options.$storageWidgetEl; + + this.entity = this.editor.options.entity; + this.predicate = this.editor.options.property; + this.editorName = this.editor.options.editorName; + + // Generate a DOM-compatible ID for the toolbar DOM element. + var propertyID = Drupal.edit.util.calcPropertyID(this.entity, this.predicate); + this._id = 'edit-toolbar-for-' + propertyID.replace(/\//g, '_'); + }, + + /** + * Listens to editor state changes. + */ + stateChange: function(from, to) { + switch (to) { + case 'inactive': + // Nothing happens in this stage. + break; + case 'candidate': + if (from !== 'inactive') { + if (from !== 'highlighted' && this.editorName !== 'form') { + this._unpad(this.editorName); + } + this.remove(); + } + break; + case 'highlighted': + // As soon as we highlight, make sure we have a toolbar in the DOM (with at least a title). + this.render(); + this.startHighlight(); + break; + case 'activating': + this.setLoadingIndicator(true); + break; + case 'active': + this.startEdit(this.editorName); + this.setLoadingIndicator(false); + if (this.editorName !== 'form') { + this._pad(this.editorName); + } + if (this.editorName === 'direct-with-wysiwyg') { + this.insertWYSIWYGToolGroups(); + } + break; + case 'changed': + this.$el + .find('a.save') + .addClass('blue-button') + .removeClass('gray-button'); + break; + case 'saving': + this.setLoadingIndicator(true); + this.save(); + break; + case 'saved': + this.setLoadingIndicator(false); + break; + case 'invalid': + this.setLoadingIndicator(false); + break; + } + }, + + /** + * Saves a property. + * + * This method deals with the complexity of the editor-dependent ways of + * inserting updated content and showing validation error messages. + * + * One might argue that this does not belong in a view. However, there is no + * actual "save" logic here, that lives in Backbone.sync. This is just some + * glue code, along with the logic for inserting updated content as well as + * showing validation error messages, the latter of which is certainly okay. + */ + save: function() { + var that = this; + var editor = this.editor; + var editableEntity = editor.options.widget; + var entity = editor.options.entity; + var predicate = editor.options.property; + + // Use Create.js' Storage widget to handle saving. (Uses Backbone.sync.) + this.$storageWidgetEl.createStorage('saveRemote', entity, { + editor: editor, + + // Successfully saved without validation errors. + success: function (model) { + editableEntity.setState('saved', predicate); + + // Replace the old content with the new content. + var updatedField = entity.get(predicate + '/rendered'); + var $inner = $(updatedField).html(); + editor.element.html($inner); + + // @todo BLOCKED_ON(VIE.js, how to let VIE know that some content was removed and how to scan new content for VIE entities, to make them editable?) + // Also see Drupal.behaviors.editDiscoverEditables. + // VIE doesn't seem to like this? :) It seems that if I delete/ + // overwrite an existing field, that VIE refuses to find the same + // predicate again for the same entity? + // self.$el.replaceWith(updatedField); + // debugger; + // console.log(self.$el, self.el, Drupal.edit.domService.findSubjectElements(self.$el)); + // Drupal.edit.domService.findSubjectElements(self.$el).each(Drupal.edit.prepareFieldView); + + editableEntity.setState('candidate', predicate); + }, + + // Save attempted but failed due to validation errors. + error: function (validationErrorMessages) { + editableEntity.setState('invalid', predicate); + + if (that.editorName === 'form') { + editor.$formContainer + .find('.edit-form') + .addClass('edit-validation-error') + .find('form') + .prepend(validationErrorMessages); + } + else { + var $errors = $('
') + .append(validationErrorMessages); + editor.element + .addClass('edit-validation-error') + .after($errors); + } + } + }); + }, + + /** + * When the user clicks the info label, nothing should happen. + * @note currently redirects the click.edit-event to the editor DOM element. + * + * @param event + */ + onClickInfoLabel: function(event) { + event.stopPropagation(); + event.preventDefault(); + // Redirects the event to the editor DOM element. + this.editor.element.trigger('click.edit'); + }, + + /** + * A mouseleave to the editor doesn't matter; a mouseleave to something else + * counts as a mouseleave on the editor itself. + * + * @param event + */ + onMouseLeave: function(event) { + var el = this.editor.element[0]; + if (event.relatedTarget != el && !$.contains(el, event.relatedTarget)) { + this.editor.element.trigger('mouseleave.edit'); + } + event.stopPropagation(); + }, + + /** + * Upon clicking "Save", trigger a custom event to save this property. + * + * @param event + */ + onClickSave: function(event) { + event.stopPropagation(); + event.preventDefault(); + this.editor.options.widget.setState('saving', this.predicate); + }, + + /** + * Upon clicking "Close", trigger a custom event to stop editing. + * + * @param event + */ + onClickClose: function(event) { + event.stopPropagation(); + event.preventDefault(); + this.editor.options.widget.setState('candidate', this.predicate, { reason: 'cancel' }); + }, + + /** + * Indicate in the 'info' toolgroup that we're waiting for a server reponse. + * + * @param bool enabled + * Whether the loading indicator should be displayed or not. + */ + setLoadingIndicator: function(enabled) { + if (enabled) { + this.addClass('info', 'loading'); + } + else { + // Only stop showing the loading indicator after half a second to prevent + // it from flashing, which is bad UX. + var that = this; + setTimeout(function() { + that.removeClass('info', 'loading'); + }, 500); + } + }, + + startHighlight: function() { + // We get the label to show for this property from VIE's type system. + var label = this.predicate; + var attributeDef = this.entity.get('@type').attributes.get(this.predicate); + if (attributeDef && attributeDef.metadata) { + label = attributeDef.metadata.label; + } + + this.$el + .find('.edit-toolbar') + // Append the "info" toolgroup into the toolbar. + .append(Drupal.theme('editToolgroup', { + classes: 'info', + buttons: [ + { label: label, classes: 'blank-button label', hasButtonRole: false } + ] + })); + + // Animations. + var that = this; + setTimeout(function () { + that.show('info'); + }, 0); + }, + + startEdit: function() { + this.$el + .addClass('edit-editing') + .find('.edit-toolbar') + // Append the "ops" toolgroup into the toolbar. + .append(Drupal.theme('editToolgroup', { + classes: 'ops', + buttons: [ + { label: Drupal.t('Save'), classes: 'field-save save gray-button' }, + { label: '', classes: 'field-close close gray-button' } + ] + })); + this.show('ops'); + }, + + /** + * Adjusts the toolbar to accomodate padding on the PropertyEditor widget. + * + * @see PropertyEditorDecorationView._pad(). + */ + _pad: function(editorName) { + // The whole toolbar must move to the top when the property's DOM element + // is displayed inline. + if (this.editor.element.css('display') === 'inline') { + this.$el.css('top', parseInt(this.$el.css('top'), 10) - 5 + 'px'); + } + + // The toolbar must move to the top and the left. + var $hf = this.$el.find('.edit-toolbar-heightfaker'); + $hf.css({ bottom: '6px', left: '-5px' }); + // When using a WYSIWYG editor, the width of the toolbar must match the + // width of the editable. + if (editorName === 'direct-with-wysiwyg') { + $hf.css({ width: this.editor.element.width() + 10 }); + } + }, + + /** + * Undoes the changes made by _pad(). + * + * @see PropertyEditorDecorationView._unpad(). + */ + _unpad: function(editorName) { + // Move the toolbar back to its original position. + var $hf = this.$el.find('.edit-toolbar-heightfaker'); + $hf.css({ bottom: '1px', left: '' }); + // When using a WYSIWYG editor, restore the width of the toolbar. + if (editorName === 'direct-with-wysiwyg') { + $hf.css({ width: '' }); + } + }, + + insertWYSIWYGToolGroups: function() { + this.$el + .find('.edit-toolbar') + .append(Drupal.theme('editToolgroup', { + classes: 'wysiwyg-tabs', + buttons: [] + })) + .append(Drupal.theme('editToolgroup', { + classes: 'wysiwyg', + buttons: [] + })); + + // Animate the toolgroups into visibility. + var that = this; + setTimeout(function () { + that.show('wysiwyg-tabs'); + that.show('wysiwyg'); + }, 0); + }, + + /** + * Renders the Toolbar's markup into the DOM. + * + * Note: depending on whether the 'display' property of the $el for which a + * toolbar is being inserted into the DOM, it will be inserted differently. + */ + render: function () { + // Render toolbar. + this.setElement($(Drupal.theme('editToolbarContainer', { + id: this.getId() + }))); + + // Insert in DOM. + if (this.$el.css('display') === 'inline') { + this.$el.prependTo(this.editor.element.offsetParent()); + var pos = this.editor.element.position(); + this.$el.css('left', pos.left).css('top', pos.top); + } + else { + this.$el.insertBefore(this.editor.element); + } + + var that = this; + // Animate the toolbar into visibility. + setTimeout(function () { + that.$el.removeClass('edit-animate-invisible'); + }, 0); + }, + + remove: function () { + if (!this.$el) { + return; + } + + // Remove after animation. + var that = this; + var $el = this.$el; + this.$el + .addClass('edit-animate-invisible') + // Prevent this toolbar from being detected *while* it is being removed. + .removeAttr('id') + .find('.edit-toolbar .edit-toolgroup') + .addClass('edit-animate-invisible') + .on(Drupal.edit.util.constants.transitionEnd, function (e) { + $el.remove(); + }); + }, + + /** + * Calculates the ID for this toolbar container. + * + * Only used to make sane hovering behavior possible. + * + * @return string + * A string that can be used as the ID for this toolbar container. + */ + getId: function() { + return this._id; + }, + + /** + * Shows a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + show: function (toolgroup) { + this._find(toolgroup).removeClass('edit-animate-invisible'); + }, + + /** + * Adds classes to a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + addClass: function (toolgroup, classes) { + this._find(toolgroup).addClass(classes); + }, + + /** + * Removes classes from a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + removeClass: function (toolgroup, classes) { + this._find(toolgroup).removeClass(classes); + }, + + /** + * Finds a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + _find: function (toolgroup) { + return this.$el.find('.edit-toolbar .edit-toolgroup.' + toolgroup); + } +}); + +})(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/lib/Drupal/edit/Ajax/BaseCommand.php b/core/modules/edit/lib/Drupal/edit/Ajax/BaseCommand.php new file mode 100644 index 0000000..32d325d --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/Ajax/BaseCommand.php @@ -0,0 +1,52 @@ +command = $command; + $this->data = $data; + } + + /** + * Implements Drupal\Core\Ajax\CommandInterface:render(). + */ + public function render() { + return array( + 'command' => $this->command, + 'data' => $this->data, + ); + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormCommand.php b/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormCommand.php new file mode 100644 index 0000000..76b01c5 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormCommand.php @@ -0,0 +1,27 @@ +register('plugin.manager.edit.processed_text_editor', 'Drupal\edit\Plugin\Type\ProcessedTextEditorManager'); + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/Plugin/ProcessedTextEditorBase.php b/core/modules/edit/lib/Drupal/edit/Plugin/ProcessedTextEditorBase.php new file mode 100644 index 0000000..41c0141 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/Plugin/ProcessedTextEditorBase.php @@ -0,0 +1,35 @@ +discovery = new AnnotatedClassDiscovery('edit', 'processed_text_editor'); + $this->discovery = new AlterDecorator($this->discovery, 'edit_wysiwyg'); + $this->discovery = new CacheDecorator($this->discovery, 'edit:wysiwyg'); + $this->factory = new DefaultFactory($this->discovery); + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/Tests/EditorSelectionTest.php b/core/modules/edit/lib/Drupal/edit/Tests/EditorSelectionTest.php new file mode 100644 index 0000000..cb09706 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/Tests/EditorSelectionTest.php @@ -0,0 +1,224 @@ + 'In-place field editor selection', + 'description' => 'Tests in-place field editor selection.', + 'group' => 'Edit', + ); + } + + /** + * Sets the default field storage backend for fields created during tests. + */ + function setUp() { + parent::setUp(); + + $this->installSchema('system', 'variable'); + $this->enableModules(array('field', 'field_sql_storage', 'field_test')); + + // Set default storage backend. + variable_set('field_storage_default', $this->default_storage); + } + + /** + * Creates a field and an instance of it. + * + * @param string $field_name + * The field name. + * @param string $type + * The field type. + * @param int $cardinality + * The field's cardinality. + * @param string $label + * The field's label (used everywhere: widget label, formatter label). + * @param array $instance_settings + * @param string $widget_type + * The widget type. + * @param array $widget_settings + * The widget settings. + * @param string $formatter_type + * The formatter type. + * @param array $formatter_settings + * The formatter settings. + */ + function createFieldWithInstance($field_name, $type, $cardinality, $label, $instance_settings, $widget_type, $widget_settings, $formatter_type, $formatter_settings) { + $field = $field_name . '_field'; + $this->$field = array( + 'field_name' => $field_name, + 'type' => $type, + 'cardinality' => $cardinality, + ); + $this->$field_name = field_create_field($this->$field); + + $instance = $field_name . '_instance'; + $this->$instance = array( + 'field_name' => $field_name, + 'entity_type' => 'test_entity', + 'bundle' => 'test_bundle', + 'label' => $label, + 'description' => $label, + 'weight' => mt_rand(0, 127), + 'settings' => $instance_settings, + 'widget' => array( + 'type' => $widget_type, + 'label' => $label, + 'settings' => $widget_settings, + ), + 'display' => array( + 'default' => array( + 'label' => 'above', + 'type' => $formatter_type, + 'settings' => $formatter_settings + ), + ), + ); + field_create_instance($this->$instance); + } + + /** + * Retrieves the FieldInstance object for the given field and returns the + * editor that Edit selects. + */ + function getSelectedEditor($items, $field_name, $display = 'default') { + $field_instance = field_info_instance('test_entity', $field_name, 'test_bundle'); + return _edit_get_field_editor($items, $field_instance, $field_instance['display'][$display]['type']); + } + + /** + * Tests a textual field, without/with text processing, with cardinality 1 and + * >1, always without a WYSIWYG editor present. + */ + function testText() { + $field_name = 'field_text'; + $this->createFieldWithInstance( + $field_name, 'text', 1, 'Simple text field', + // Instance settings. + array('text_processing' => 0), + // Widget type & settings. + 'text_textfield', + array('size' => 42), + // 'default' formatter type & settings. + 'text_default', + array() + ); + + // Pretend there is an entity with these items for the field. + $items = array(array('value' => 'Hello, world!', 'format' => 'full_html')); + + // Editor selection without text processing, with cardinality 1. + $this->assertEqual('direct', $this->getSelectedEditor($items, $field_name), "Without text processing, cardinality 1, the 'direct' editor is selected."); + + // Editor selection with text processing, cardinality 1. + $this->field_text_instance['settings']['text_processing'] = 1; + field_update_instance($this->field_text_instance); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With text processing, cardinality 1, the 'form' editor is selected."); + + // Editor selection without text processing, cardinality 1 (again). + $this->field_text_instance['settings']['text_processing'] = 0; + field_update_instance($this->field_text_instance); + $this->assertEqual('direct', $this->getSelectedEditor($items, $field_name), "Without text processing again, cardinality 1, the 'direct' editor is selected."); + + // Editor selection without text processing, cardinality >1 + $this->field_text_field['cardinality'] = 2; + field_update_field($this->field_text_field); + $items[] = array('value' => 'Hallo, wereld!', 'format' => 'full_html'); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "Without text processing, cardinality >1, the 'form' editor is selected."); + + // Editor selection with text processing, cardinality >1 + $this->field_text_instance['settings']['text_processing'] = 1; + field_update_instance($this->field_text_instance); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With text processing, cardinality >1, the 'form' editor is selected."); + } + + /** + * Tests a textual field, with text processing, with cardinality 1 and >1, + * always with a ProcessedTextEditor plug-in present, but with varying text + * format compatibility. + */ + function testTextWysiwyg() { + $field_name = 'field_textarea'; + $this->createFieldWithInstance( + $field_name, 'text', 1, 'Long text field', + // Instance settings. + array('text_processing' => 1), + // Widget type & settings. + 'text_textarea', + array('size' => 42), + // 'default' formatter type & settings. + 'text_default', + array() + ); + + // ProcessedTextEditor plug-in compatible with the full_html text format. + state()->set('edit_test.compatible_format', 'full_html'); + + // Pretend there is an entity with these items for the field. + $items = array(array('value' => 'Hello, world!', 'format' => 'filtered_html')); + + // Editor selection with cardinality 1, without compatible text format. + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "Without cardinality 1, and the filtered_html text format, the 'form' editor is selected."); + + // Editor selection with cardinality 1, with compatible text format. + $items[0]['format'] = 'full_html'; + $this->assertEqual('direct-with-wysiwyg', $this->getSelectedEditor($items, $field_name), "With cardinality 1, and the full_html text format, the 'direct-with-wysiwyg' editor is selected."); + + // Editor selection with text processing, cardinality >1 + $this->field_textarea_field['cardinality'] = 2; + field_update_field($this->field_textarea_field); + $items[] = array('value' => 'Hallo, wereld!', 'format' => 'full_html'); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With cardinality >1, and both items using the full_html text format, the 'form' editor is selected."); + } + + /** + * Tests a number field, with cardinality 1 and >1. + */ + function testNumber() { + $field_name = 'field_nr'; + $this->createFieldWithInstance( + $field_name, 'number_integer', 1, 'Simple number field', + // Instance settings. + array(), + // Widget type & settings. + 'number', + array(), + // 'default' formatter type & settings. + 'number_integer', + array() + ); + + // Pretend there is an entity with these items for the field. + $items = array(42, 43); + + // Editor selection with cardinality 1. + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With cardinality 1, the 'form' editor is selected."); + + // Editor selection with cardinality >1. + $this->field_nr_field['cardinality'] = 2; + field_update_field($this->field_nr_field); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With cardinality >1, the 'form' editor is selected."); + } + +} diff --git a/core/modules/edit/tests/modules/edit_test.info b/core/modules/edit/tests/modules/edit_test.info new file mode 100644 index 0000000..4df4a3f --- /dev/null +++ b/core/modules/edit/tests/modules/edit_test.info @@ -0,0 +1,6 @@ +name = Edit test +description = Support module for the Edit module tests. +core = 8.x +package = Testing +version = VERSION +hidden = TRUE diff --git a/core/modules/edit/tests/modules/edit_test.module b/core/modules/edit/tests/modules/edit_test.module new file mode 100644 index 0000000..d74528d --- /dev/null +++ b/core/modules/edit/tests/modules/edit_test.module @@ -0,0 +1,6 @@ +get('edit_test.compatible_format') == $format_id; + } + +} diff --git a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextDefaultFormatter.php b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextDefaultFormatter.php index 6b34ba9..79bdbc7 100644 --- a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextDefaultFormatter.php +++ b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextDefaultFormatter.php @@ -23,6 +23,9 @@ * "text", * "text_long", * "text_with_summary" + * }, + * edit = { + * "editor" = "direct" * } * ) */ diff --git a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextPlainFormatter.php b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextPlainFormatter.php index 0f7b615..2695351 100644 --- a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextPlainFormatter.php +++ b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextPlainFormatter.php @@ -23,6 +23,9 @@ * "text", * "text_long", * "text_with_summary" + * }, + * edit = { + * "editor" = "direct" * } * ) */ diff --git a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextSummaryOrTrimmedFormatter.php b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextSummaryOrTrimmedFormatter.php index 11f0c14..b318da1 100644 --- a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextSummaryOrTrimmedFormatter.php +++ b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextSummaryOrTrimmedFormatter.php @@ -22,6 +22,9 @@ * }, * settings = { * "trim_length" = "600" + * }, + * edit = { + * "editor" = "form" * } * ) */ diff --git a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextTrimmedFormatter.php b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextTrimmedFormatter.php index 349cf63..05a830a 100644 --- a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextTrimmedFormatter.php +++ b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextTrimmedFormatter.php @@ -31,6 +31,9 @@ * }, * settings = { * "trim_length" = "600" + * }, + * edit = { + * "editor" = "form" * } * ) */