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%Pv s=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```00 mi IDATx^= H4ͼ!KsfQGx"LCyל(ux;z KA.Jo +E wy/2cdD@ҔLO%8F ?Q IENDB` \ 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 + + IHDR j `PLTE [ tRNS@ P00p`ϟ Dƙ IDATxe DQ8Ϩ/BDU9xV+D\?x@qWcF8wicS B}?v;Vf.V$JgX=Kضp0XS"iRw\:LL\~;Z5wu 5E)L IENDB` \ 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 + + IHDR j PLTE̻ʪ̡̜ˠ̣̽¼Ƿ ʨªZ(e +tRNSϟ `π@`0p0p`0Pϟϟ c IDATxeW0 {W +H"ʵ,y{Hpyo?mf,RBRxBvL;&LPJaRb\(Tbn(1wϔJ)ԈkS +58äT^4 P6c}[i <ާ'-+HP>K IENDB` \ 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{lQ zK ! , `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 ! , h9C 4kk\ &I&l )F-P@chH!ш4< + x@R`0C"8h0BfgX(rc}Y +1 ! , aйҚX]mKl[)"aĢ\*"$t	@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 += '