Issue #1824500 by Wim Leers, tkoleary, frega, jessebeach, henribergius, effulgentsia, nod_, yched: In-place editing for Fields.

8.0.x
Dries 2012-12-21 12:03:57 -05:00
parent b5ac4a523f
commit c85c994db2
52 changed files with 4836 additions and 0 deletions

View File

@ -0,0 +1,410 @@
/**
* 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;
}
.edit-animate-only-background-and-padding {
-webkit-transition: background, padding .2s ease;
-moz-transition: background, padding .2s ease;
-ms-transition: background, padding .2s ease;
-o-transition: background, padding .2s ease;
transition: background, padding .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;
}
/* In-place editing doesn't work in the overlay, so always hide the tab. */
.overlay-open .toolbar .icon-edit {
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-wrapper .form-wrapper { margin: inherit; }
.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 button,
.edit-toolbar button {
float: left; /* LTR */
display: block;
height: 29px;
min-width: 29px;
padding: 3px 6px 6px 6px;
margin: 4px 5px 1px 0;
border: 1px solid #fff;
border-radius: 3px;
color: white;
text-decoration: none;
font-size: 13px;
cursor: pointer;
}
#edit_modal button {
float: none;
display: inline-block;
}
/* Button with icons. */
#edit_modal button span,
.edit-toolbar button span {
width: 22px;
height: 19px;
display: block;
float: left;
}
.edit-toolbar span.close {
background: url('../images/close.png') no-repeat 3px 2px;
text-indent: -999em;
direction: ltr;
}
.edit-toolbar button.blank-button {
color: black;
background-color: #fff;
font-weight: bolder;
}
#edit_modal button.blue-button,
.edit-toolbar button.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 button.gray-button,
.edit-toolbar button.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 button.blue-button:hover,
.edit-toolbar button.blue-button:hover,
#edit_modal button.blue-button:active,
.edit-toolbar button.blue-button:active {
border: 1px solid #55a5d3;
box-shadow: 0 2px 1px rgba(0,0,0,0.2);
}
#edit_modal button.gray-button:hover,
.edit-toolbar button.gray-button:hover,
#edit_modal button.gray-button:active,
.edit-toolbar button.gray-button:active {
border: 1px solid #cdcdcd;
box-shadow: 0 2px 1px rgba(0,0,0,0.1);
}

View File

@ -0,0 +1,6 @@
name = Edit
description = In-place content editing.
package = Core
core = 8.x
dependencies[] = field

View File

@ -0,0 +1,159 @@
<?php
/**
* @file
* Provides in-place content editing functionality for fields.
*
* The Edit module makes content editable in-place. Rather than having to visit
* a separate page to edit content, it may be edited in-place.
*
* Technically, this module adds classes and data- attributes to fields and
* entities, enabling them for in-place editing.
*/
use Drupal\Core\Entity\EntityInterface;
use Drupal\edit\Form\EditFieldForm;
/**
* Implements hook_custom_theme().
*
* @todo Add an event subscriber to the Ajax system to automatically set the
* base page theme for all Ajax requests, and then remove this one off.
*/
function edit_custom_theme() {
if (substr(current_path(), 0, 5) === 'edit/') {
return ajax_base_page_theme();
}
}
/**
* Implements hook_permission().
*/
function edit_permission() {
return array(
'access in-place editing' => array(
'title' => t('Access in-place editing'),
),
);
}
/**
* Implements hook_toolbar().
*/
function edit_toolbar() {
if (!user_access('access in-place editing')) {
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(
'#attached' => array(
'library' => array(
array('edit', 'edit'),
),
),
),
);
// Include the attachments and settings for all available editors.
$attachments = drupal_container()->get('edit.editor.selector')->getAllEditorAttachments();
$tab['edit']['tray']['#attached'] = array_merge_recursive($tab['edit']['tray']['#attached'], $attachments);
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(
'metadataURL' => url('edit/metadata'),
'fieldFormURL' => url('edit/form/!entity_type/!id/!field_name/!langcode/!view_mode'),
'rerenderProcessedTextURL' => url('edit/text/!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_preprocess_HOOK() for field.tpl.php.
*/
function edit_preprocess_field(&$variables) {
$element = $variables['element'];
$entity = $element['#object'];
$variables['attributes']['data-edit-id'] = $entity->entityType() . ':' . $entity->id() . ':' . $element['#field_name'] . ':' . $element['#language'] . ':' . $element['#view_mode'];
}
/**
* Form constructor for the field editing form.
*
* @ingroup forms
*/
function edit_field_form(array $form, array &$form_state, EntityInterface $entity, $field_name) {
$form_handler = new EditFieldForm();
return $form_handler->build($form, $form_state, $entity, $field_name);
}

View File

@ -0,0 +1,22 @@
edit_metadata:
pattern: '/edit/metadata'
defaults:
_controller: '\Drupal\edit\EditController::metadata'
requirements:
_permission: 'access in-place editing'
edit_field_form:
pattern: '/edit/form/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode}'
defaults:
_controller: '\Drupal\edit\EditController::fieldForm'
requirements:
_permission: 'access in-place editing'
_access_edit_entity_field: 'TRUE'
edit_text:
pattern: '/edit/text/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode}'
defaults:
_controller: '\Drupal\edit\EditController::getUntransformedText'
requirements:
_permission: 'access in-place editing'
_access_edit_entity_field: 'TRUE'

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

528
core/modules/edit/js/app.js Normal file
View File

@ -0,0 +1,528 @@
/**
* @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: null,
/**
* 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);
this.$entityElements = $([]);
// Use Create's Storage widget.
this.$el.createStorage({
vie: this.vie,
editableNs: 'createeditable'
});
// Instantiate OverlayView.
var overlayView = new Drupal.edit.views.OverlayView({
el: (Drupal.theme('editOverlay', {})),
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);
},
/**
* Finds editable properties within a given context.
*
* Finds editable properties, registers them with the app, updates their
* state to match the current app state.
*
* @param $context
* A jQuery-wrapped context DOM element within which will be searched.
*/
findEditableProperties: function($context) {
var that = this;
var newState = (this.model.get('isViewing')) ? 'inactive' : 'candidate';
this.domService.findSubjectElements($context).each(function() {
var $element = $(this);
// Ignore editable properties for which we've already set up Create.js.
if (that.$entityElements.index($element) !== -1) {
return;
}
$element
// Instantiate an EditableEntity widget.
.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);
}
})
// This event is triggered just before Edit removes an EditableEntity
// widget, so that we can do proper clean-up.
.on('destroyedPropertyEditor.edit', function(event, editor) {
that.undecorateEditor(editor);
that.$entityElements = that.$entityElements.not($(this));
})
// Transition the new PropertyEditor into the current state.
.createEditable('setState', newState);
// Add this new EditableEntity widget element to the list.
that.$entityElements = that.$entityElements.add($element);
});
},
/**
* 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);
});
// Manage the page's tab indexes.
if (newState === 'candidate') {
this._manageDocumentFocus();
Drupal.edit.setMessage(Drupal.t('In place edit mode is active'), Drupal.t('Page navigation is limited to editable items.'), Drupal.t('Press escape to exit'));
}
else if (newState === 'inactive') {
this._releaseDocumentFocusManagement();
Drupal.edit.setMessage(Drupal.t('Edit mode is inactive.'), Drupal.t('Resume normal page navigation'));
}
},
/**
* 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', type: 'submit', 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);
Drupal.edit.setMessage(Drupal.t('An editor is active'));
}
else if (this.model.get('activeEditor') === editor && to === 'candidate') {
// Discarded if it transitions from a changed state to 'candidate'.
if (from === 'changed' || from === 'invalid') {
// Retrieve the storage widget from DOM.
var createStorageWidget = this.$el.data('createStorage');
// Revert changes in the model, this will trigger the direct editable
// content to be reset and redrawn.
createStorageWidget.revertChanges(editor.options.entity);
}
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);
});
},
/**
* Undecorates an editor (PropertyEditor).
*
* Whenever a property has been updated, the old HTML will be replaced by
* the new (re-rendered) HTML. The EditableEntity widget will be destroyed,
* as will be the PropertyEditor widget. This method ensures Edit's editor
* views also are removed properly.
*
* @param editor
* The PropertyEditor widget object.
*/
undecorateEditor: function(editor) {
editor.toolbarView.undelegateEvents();
editor.toolbarView.remove();
delete editor.toolbarView;
editor.decorationView.undelegateEvents();
// Don't call .remove() on the decoration view, because that would remove
// a potentially rerendered field.
delete editor.decorationView;
},
/**
* Makes elements other than the editables unreachable via the tab key.
*
* @todo refactoring.
*
* This method is currently overloaded, handling elements of state modeling
* and application control. The state of the application is spread between
* this view, its model and aspects of the UI widgets in Create.js. In order
* to drive focus management from the application state (and have it
* influence that state of the application), we need to distall state out
* of Create.js components.
*
* This method introduces behaviors that support accessibility of the edit
* application. Although not yet integrated into the application properly,
* it does provide us with the opportunity to collect feedback from
* users who will interact with edit primarily through keyboard input. We
* want this feedback sooner than we can have a refactored application.
*/
_manageDocumentFocus: function () {
var editablesSelector = '.edit-candidate.edit-editable';
var inputsSelector = 'a:visible, button:visible, input:visible, textarea:visible, select:visible';
var $editables = $(editablesSelector)
.attr({
'tabindex': 0,
'role': 'button'
});
// Instantiate a variable to hold the editable element in the set.
var $currentEditable;
// We're using simple function scope to manage 'this' for the internal
// handler, so save this as that.
var that = this;
// Turn on focus management.
$(document).on('keydown.edit', function (event) {
var activeEditor, editableEntity, predicate;
// Handle esc key press. Close any active editors.
if (event.keyCode === 27) {
event.preventDefault();
activeEditor = that.model.get('activeEditor');
if (activeEditor) {
editableEntity = activeEditor.options.widget;
predicate = activeEditor.options.property;
editableEntity.setState('candidate', predicate, { reason: 'overlay' });
}
else {
$(editablesSelector).trigger('tabOut.edit');
// This should move into the state management for the app model.
location.hash = "#view";
that.model.set('isViewing', true);
}
return;
}
// Handle enter or space key presses.
if (event.keyCode === 13 || event.keyCode === 32) {
if ($currentEditable && $currentEditable.is(editablesSelector)) {
$currentEditable.trigger('click');
// Squelch additional handlers.
event.preventDefault();
return;
}
}
// Handle tab key presses.
if (event.keyCode === 9) {
var context = '';
// Include the view mode toggle with the editables selector.
var selector = editablesSelector + ', #toolbar-tab-edit';
activeEditor = that.model.get('activeEditor');
var $confirmDialog = $('#edit_modal');
// If the edit modal is active, that is the tabbing context.
if ($confirmDialog.length) {
context = $confirmDialog;
selector = inputsSelector;
if (!$currentEditable || $currentEditable.is(editablesSelector)) {
$currentEditable = $(selector, context).eq(-1);
}
}
// If an editor is active, then the tabbing context is the editor and
// its toolbar.
else if (activeEditor) {
context = $(activeEditor.$formContainer).add(activeEditor.toolbarView.$el);
// Include the view mode toggle with the editables selector.
selector = inputsSelector;
if (!$currentEditable || $currentEditable.is(editablesSelector)) {
$currentEditable = $(selector, context).eq(-1);
}
}
// Otherwise the tabbing context is the list of editable predicates.
var $editables = $(selector, context);
if (!$currentEditable) {
$currentEditable = $editables.eq(-1);
}
var count = $editables.length - 1;
var index = $editables.index($currentEditable);
// Navigate backwards.
if (event.shiftKey) {
// Beginning of the set, loop to the end.
if (index === 0) {
index = count;
}
else {
index -= 1;
}
}
// Navigate forewards.
else {
// End of the set, loop to the start.
if (index === count) {
index = 0;
}
else {
index += 1;
}
}
// Tab out of the current editable.
$currentEditable.trigger('tabOut.edit');
// Update the current editable.
$currentEditable = $editables
.eq(index)
.focus()
.trigger('tabIn.edit');
// Squelch additional handlers.
event.preventDefault();
event.stopPropagation();
}
});
// Set focus on the edit button initially.
$('#toolbar-tab-edit').focus();
},
/**
* Removes key management and edit accessibility features from the DOM.
*/
_releaseDocumentFocusManagement: function () {
$(document).off('keydown.edit');
$('.edit-allowed.edit-field').removeAttr('tabindex role');
}
});
})(jQuery, _, Backbone, Drupal, VIE);

View File

@ -0,0 +1,164 @@
/**
* @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] = undefined;
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 <input> 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();
// 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] = jQuery(response.data).find('.field-item').html();
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 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);

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,150 @@
/**
* @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')
.attr('role', 'dialog')
.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);

View File

@ -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);

View File

@ -0,0 +1,147 @@
/**
* @file
* Behaviors for Edit, including the one that initializes Edit's EditAppView.
*/
(function ($, _, Backbone, Drupal, drupalSettings) {
"use strict";
/**
* The edit ARIA live message area.
*
* @todo Eventually the messages area should be converted into a Backbone View
* that will respond to changes in the application's model. For the initial
* implementation, we will call the Drupal.edit.setMessage method when an aural
* message should be read by the user agent.
*/
var $messages;
Drupal.edit = Drupal.edit || {};
Drupal.edit.metadataCache = Drupal.edit.metadataCache || {};
/**
* Attach toggling behavior and in-place editing.
*/
Drupal.behaviors.edit = {
attach: function(context) {
var $context = $(context);
var $fields = $context.find('[data-edit-id]');
// Initialize the Edit app.
$context.find('#toolbar-tab-edit').once('edit-init', Drupal.edit.init);
var annotateField = function(field) {
if (_.has(Drupal.edit.metadataCache, field.editID)) {
var meta = Drupal.edit.metadataCache[field.editID];
field.$el.addClass((meta.access) ? 'edit-allowed' : 'edit-disallowed');
if (meta.access) {
field.$el
.attr('data-edit-field-label', meta.label)
.attr('aria-label', meta.aria)
.addClass('edit-field edit-type-' + meta.editor);
if (meta.editor === 'direct-with-wysiwyg') {
field.$el
// This editor also uses the Backbone.syncDirect saving mechanism.
.addClass('edit-type-direct')
.attr('data-edit-text-format', meta.format)
.addClass((meta.formatHasTransformations) ? 'edit-text-with-transformation-filters' : 'edit-text-without-transformation-filters');
}
}
return true;
}
return false;
};
// Find all fields in the context without metadata.
var fieldsToAnnotate = _.map($fields.not('.edit-allowed, .edit-disallowed'), function(el) {
var $el = $(el);
return { $el: $el, editID: $el.attr('data-edit-id') };
});
// Fields whose metadata is known (typically when they were just modified)
// can be annotated immediately, those remaining must be requested.
var remainingFieldsToAnnotate = _.reduce(fieldsToAnnotate, function(result, field) {
if (!annotateField(field)) {
result.push(field);
}
return result;
}, []);
// Make fields that could be annotated immediately available for editing.
Drupal.edit.app.findEditableProperties($context);
if (remainingFieldsToAnnotate.length) {
$(window).ready(function() {
$.ajax({
url: drupalSettings.edit.metadataURL,
type: 'POST',
data: { 'fields[]' : _.pluck(remainingFieldsToAnnotate, 'editID') },
dataType: 'json',
success: function(results) {
// Update the metadata cache.
_.each(results, function(metadata, editID) {
Drupal.edit.metadataCache[editID] = metadata;
});
// Annotate the remaining fields based on the updated access cache.
_.each(remainingFieldsToAnnotate, annotateField);
// As soon as there is at least one editable field, show the Edit
// tab in the toolbar.
if ($fields.filter('.edit-allowed').length) {
$('.toolbar .icon-edit.edit-nothing-editable-hidden')
.removeClass('edit-nothing-editable-hidden');
}
// Find editable fields, make them editable.
Drupal.edit.app.findEditableProperties($context);
}
});
});
}
}
};
Drupal.edit.init = function() {
// Append a messages element for appending interaction updates for screen
// readers.
$messages = $(Drupal.theme('editMessageBox')).appendTo($(this).parent());
// 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();
// For now, we work with a singleton app, because for Drupal.behaviors to be
// able to discover new editable properties that get AJAXed in, it must know
// with which app instance they should be associated.
Drupal.edit.app = app;
};
/**
* Places the message in the edit ARIA live message area.
*
* The message will be read by speaking User Agents.
*
* @param {String} message
* A string to be inserted into the message area.
*/
Drupal.edit.setMessage = function(message) {
var args = Array.prototype.slice.call(arguments);
args.unshift('editMessage');
$messages.html(Drupal.theme.apply(this, args));
};
})(jQuery, _, Backbone, Drupal, drupalSettings);

View File

@ -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);

View File

@ -0,0 +1,59 @@
/**
* @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: {
"edit": "edit",
"view": "view",
"": "view"
},
initialize: function(options) {
this.appModel = options.appModel;
var that = this;
this.appModel.on('change:isViewing', function() {
that.navigate(that.appModel.get('isViewing') ? '#view' : '#edit');
});
},
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.appModel.set('isViewing', false);
}
});
}
// Otherwise, we can switch to view mode directly.
else {
that.appModel.set('isViewing', true);
}
}
});
})(Backbone, Drupal);

View File

@ -0,0 +1,175 @@
/**
* @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 += '<div id="edit_overlay" />';
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 += '<div id="' + settings.id + '" />';
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 += '<div id="edit_modal" class="' + classes + '" role="dialog">';
html += ' <div class="main"><p></p></div>';
html += ' <div class="actions"></div>';
html += '</div>';
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 += '<div id="' + settings.id + '" class="edit-toolbar-container edit-animate-invisible edit-animate-only-visibility">';
html += ' <div class="edit-toolbar-heightfaker edit-animate-fast">';
html += ' <div class="edit-toolbar primary" />';
html += ' </div>';
html += '</div>';
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 += '<div class="' + classes + ' ' + settings.classes + '">';
html += Drupal.theme('editButtons', { buttons: settings.buttons });
html += '</div>';
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:
* - type: the type of the button (defaults to 'button')
* - classes: the classes of the button.
* - label: the label of the 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('type')) {
button.type = 'button';
}
html += '<button type="' + button.type + '" class="' + button.classes + '"';
html += (button.action) ? ' data-edit-modal-action="' + button.action + '"' : '';
html += '>';
html += button.label;
html += '</button>';
}
return html;
};
/**
* Theme function for a form container of the Edit module.
*
* @param settings
* An object with the following keys:
* - id: the id to apply to the toolbar container.
* - loadingMsg: The message to show while loading.
* @return
* The corresponding HTML.
*/
Drupal.theme.editFormContainer = function(settings) {
var html = '';
html += '<div id="' + settings.id + '" class="edit-form-container">';
html += ' <div class="edit-form">';
html += ' <div class="placeholder">';
html += settings.loadingMsg;
html += ' </div>';
html += ' </div>';
html += '</div>';
return html;
};
/**
* A region to post messages that a screen reading UA will announce.
*
* @return {String}
* A string representing a DOM fragment.
*/
Drupal.theme.editMessageBox = function() {
return '<div id="edit-messages" class="element-invisible" role="region" aria-live="polite"></div>';
};
/**
* Wrap message strings in p tags.
*
* @return {String}
* A string representing a DOM fragment.
*/
Drupal.theme.editMessage = function() {
var messages = Array.prototype.slice.call(arguments);
var output = '';
for (var i = 0; i < messages.length; i++) {
output += '<p>' + messages[i] + '</p>';
}
return output;
};
})(jQuery, Drupal);

View File

@ -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);

View File

@ -0,0 +1,297 @@
/**
* @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);
},
_getViewForElement:function (element, collectionView) {
var viewInstance;
jQuery.each(this.views, function () {
if (jQuery(this.el).get(0) === element.get(0)) {
if (collectionView && !this.template) {
return true;
}
viewInstance = this;
return false;
}
});
return viewInstance;
},
_registerEntityView:function (entity, element, isNew) {
if (!element.length) {
return;
}
// Let's only have this overhead for direct types. Form-based editors are
// handled in backbone.drupalform.js and the PropertyEditor instance.
if (!jQuery(element).hasClass('edit-type-direct')) {
return;
}
var service = this;
var viewInstance = this._getViewForElement(element);
if (viewInstance) {
return viewInstance;
}
viewInstance = new this.vie.view.Entity({
model:entity,
el:element,
tagName:element.get(0).nodeName,
vie:this.vie,
service:this.name
});
this.views.push(viewInstance);
return viewInstance;
},
save: function(saveable) {
var correct = saveable instanceof this.vie.Savable;
if (!correct) {
throw "Invalid Savable passed";
}
if (!saveable.options.element) {
// FIXME: we could find element based on subject
throw "Unable to write entity to edit.module-markup, no element given";
}
if (!saveable.options.entity) {
throw "Unable to write to edit.module-markup, no entity given";
}
var $element = jQuery(saveable.options.element);
this._writeEntity(saveable.options.entity, saveable.options.element);
saveable.resolve();
},
_writeEntity:function (entity, element) {
var service = this;
this.findPredicateElements(this.getElementSubject(element), element, true).each(function () {
var predicateElement = jQuery(this);
var predicate = service.getElementPredicate(predicateElement);
if (!entity.has(predicate)) {
return true;
}
var value = entity.get(predicate);
if (value && value.isCollection) {
// Handled by CollectionViews separately
return true;
}
if (value === service.readElementValue(predicate, predicateElement)) {
return true;
}
// Unlike in the VIE's RdfaService no (re-)mapping needed here.
predicateElement.html(value);
});
return true;
},
// The edit-id data attribute contains the full identifier of
// each entity element in the format
// `<entity type>:<id>:<field name>:<language code>:<view mode>`.
_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
// `<entity type>/<id>`.
getElementSubject: function (element) {
return this._getID(element).split(':').slice(0, 2).join('/');
},
// Returns the field name for an element in format
// `<field name>/<language code>/<view mode>`.
// (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);
}
var entityInstance = new this.vie.Entity(entity);
entityInstance = this.vie.entities.addOrUpdate(entityInstance, {
updateOptions: {
silent: true,
ignoreChanges: true
}
});
this._registerEntityView(entityInstance, element);
return entityInstance;
},
_registerType: function (typeId, element) {
typeId = '<http://viejs.org/ns/' + 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(predicate, predicateElement);
if (value === null && !emptyValues) {
return;
}
entityPredicates[predicate] = value;
entityPredicates[predicate + '/rendered'] = predicateElement[0].outerHTML;
});
return entityPredicates;
},
readElementValue : function(predicate, element) {
// Unlike in RdfaService there is parsing needed here.
if (element.hasClass('edit-type-form')) {
return undefined;
}
else {
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();
// Make sure that element is wrapped by jQuery.
var $element = jQuery(element);
// 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);

View File

@ -0,0 +1,82 @@
/**
* @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({
events: {
'click #toolbar-tab-edit': 'editClickHandler'
},
/**
* Implements Backbone Views' initialize() function.
*/
initialize: function() {
_.bindAll(this, 'stateChange');
this.model.on('change:isViewing', this.stateChange);
// @todo
// Re-implement hook_toolbar and the corresponding JavaScript behaviors
// once https://drupal.org/node/1847198 is resolved. The toolbar tray is
// necessary when the page request is processed because its render element
// has an #attached property with the Edit module library code assigned to
// it. Currently a toolbar tab is not passed as a renderable array, so
// #attached properties are not processed. The toolbar tray DOM element is
// unnecessary right now, so it is removed.
this.$el.find('#toolbar-tray-edit').remove();
// Respond to clicks on other toolbar tabs. This temporary pending
// improvements to the toolbar module.
$('#toolbar-administration').on('click.edit', '.bar a:not(#toolbar-tab-edit)', _.bind(function (event) {
this.model.set('isViewing', true);
}, this));
// We have to call stateChange() here because URL fragments are not passed
// to the server, thus the wrong anchor may be marked as active.
this.stateChange();
},
/**
* Listens to app state changes.
*/
stateChange: function() {
var isViewing = this.model.get('isViewing');
// Toggle the state of the Toolbar Edit tab based on the isViewing state.
this.$el.find('#toolbar-tab-edit')
.toggleClass('active', !isViewing)
.attr('aria-pressed', !isViewing);
// Manage the toolbar state until
// https://drupal.org/node/1847198 is resolved
if (!isViewing) {
// Remove the 'toolbar-tray-open' class from the body element.
this.$el.removeClass('toolbar-tray-open');
// Deactivate any other active tabs and trays.
this.$el
.find('.bar a', '#toolbar-administration')
.not('#toolbar-tab-edit')
.add('.tray', '#toolbar-administration')
.removeClass('active');
// Set the height of the toolbar.
if ('toolbar' in Drupal) {
Drupal.toolbar.setHeight();
}
}
},
/**
* Handles clicks on the edit tab of the toolbar.
*
* @param {Object} event
*/
editClickHandler: function (event) {
var isViewing = this.model.get('isViewing');
// Toggle the href of the Toolbar Edit tab based on the isViewing state. The
// href value should represent to state to be entered.
this.$el.find('#toolbar-tab-edit').attr('href', (isViewing) ? '#edit' : '#view');
this.model.set('isViewing', !isViewing);
}
});
})(jQuery, _, Backbone, Drupal);

View File

@ -0,0 +1,107 @@
/**
* @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 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);
Drupal.edit.setMessage(Drupal.t('Confirmation dialog open'));
},
/**
* 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);

View File

@ -0,0 +1,86 @@
/**
* @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);
// Add the overlay to the page.
this.$el
.addClass('edit-animate-slow edit-animate-invisible')
.hide()
.appendTo('body');
},
/**
* 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 {Object} 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' });
}
else {
this.model.set('isViewing', true);
}
},
/**
* Reveal the overlay element.
*/
render: function () {
this.$el
.show()
.css('top', $('#navbar').outerHeight())
.removeClass('edit-animate-invisible');
},
/**
* Hide the overlay element.
*/
remove: function () {
var that = this;
this.$el
.addClass('edit-animate-invisible')
.on(Drupal.edit.util.constants.transitionEnd, function (event) {
that.$el.hide();
});
}
});
})(jQuery, _, Backbone, Drupal);

View File

@ -0,0 +1,324 @@
/**
* @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',
'tabIn.edit': 'onMouseEnter',
'tabOut.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: <value>" 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);

View File

@ -0,0 +1,465 @@
/**
* @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,
_loader: null,
_loaderVisibleStart: 0,
_id: null,
events: {
'click.edit button.label': 'onClickInfoLabel',
'mouseleave.edit': 'onMouseLeave',
'click.edit button.field-save': 'onClickSave',
'click.edit button.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;
this._loader = null;
this._loaderVisibleStart = 0;
// 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('button.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);
// Now that the changes to this property have been saved, the saved
// attributes are now the "original" attributes.
entity._originalAttributes = entity._previousAttributes = _.clone(entity.attributes);
// Get data necessary to rerender property before it is unavailable.
var updatedProperty = entity.get(predicate + '/rendered');
var $propertyWrapper = editor.element.closest('.edit-field');
var $context = $propertyWrapper.parent();
editableEntity.setState('candidate', predicate);
// Unset the property, because it will be parsed again from the DOM, iff
// its new value causes it to still be rendered.
entity.unset(predicate, { silent: true });
entity.unset(predicate + '/rendered', { silent: true });
// Trigger event to allow for proper clean-up of editor-specific views.
editor.element.trigger('destroyedPropertyEditor.edit', editor);
// Replace the old content with the new content.
$propertyWrapper.replaceWith(updatedProperty);
Drupal.attachBehaviors($context);
},
// 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 = $('<div class="edit-validation-errors"></div>')
.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' });
},
/**
* Indicates in the 'info' toolgroup that we're waiting for a server reponse.
*
* Prevents flickering loading indicator by only showing it after 0.6 seconds
* and if it is shown, only hiding it after another 0.6 seconds.
*
* @param bool enabled
* Whether the loading indicator should be displayed or not.
*/
setLoadingIndicator: function(enabled) {
var that = this;
if (enabled) {
this._loader = setTimeout(function() {
that.addClass('info', 'loading');
that._loaderVisibleStart = new Date().getTime();
}, 600);
}
else {
var currentTime = new Date().getTime();
clearTimeout(this._loader);
if (this._loaderVisibleStart) {
setTimeout(function() {
that.removeClass('info', 'loading');
}, this._loaderVisibleStart + 600 - currentTime);
}
this._loader = null;
this._loaderVisibleStart = 0;
}
},
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 edit-animate-only-background-and-padding',
buttons: [
{ label: label, classes: 'blank-button label' }
]
}));
// 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'), type: 'submit', classes: 'field-save save gray-button' },
{ label: '<span class="close">' + Drupal.t('Close') + '</span>', 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);

View File

@ -0,0 +1,78 @@
<?php
/**
* @file
* Contains \Drupal\edit\Access\EditEntityFieldAccessCheck.
*/
namespace Drupal\edit\Access;
use Drupal\Core\Access\AccessCheckInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\Core\Entity\EntityInterface;
/**
* Access check for editing entity fields.
*/
class EditEntityFieldAccessCheck implements AccessCheckInterface, EditEntityFieldAccessCheckInterface {
/**
* Implements AccessCheckInterface::applies().
*/
public function applies(Route $route) {
return array_key_exists('_access_edit_entity_field', $route->getRequirements());
}
/**
* Implements AccessCheckInterface::access().
*/
public function access(Route $route, Request $request) {
// @todo Request argument validation and object loading should happen
// elsewhere in the request processing pipeline:
// http://drupal.org/node/1798214.
$this->validateAndUpcastRequestAttributes($request);
return $this->accessEditEntityField($request->attributes->get('entity'), $request->attributes->get('field_name'));
}
/**
* Implements EntityFieldAccessCheckInterface::accessEditEntityField().
*/
public function accessEditEntityField(EntityInterface $entity, $field_name) {
$entity_type = $entity->entityType();
// @todo Generalize to all entity types: http://drupal.org/node/1839516.
return ($entity_type == 'node' && node_access('update', $entity) && field_access('edit', $field_name, $entity_type, $entity));
}
/**
* Validates and upcasts request attributes.
*/
protected function validateAndUpcastRequestAttributes(Request $request) {
// Load the entity.
if (!is_object($entity = $request->attributes->get('entity'))) {
$entity_id = $entity;
$entity_type = $request->attributes->get('entity_type');
if (!$entity_type || !entity_get_info($entity_type)) {
throw new NotFoundHttpException();
}
$entity = entity_load($entity_type, $entity_id);
if (!$entity) {
throw new NotFoundHttpException();
}
$request->attributes->set('entity', $entity);
}
// Validate the field name and language.
$field_name = $request->attributes->get('field_name');
if (!$field_name || !field_info_instance($entity->entityType(), $field_name, $entity->bundle())) {
throw new NotFoundHttpException();
}
$langcode = $request->attributes->get('langcode');
if (!$langcode || (field_valid_language($langcode) !== $langcode)) {
throw new NotFoundHttpException();
}
}
}

View File

@ -0,0 +1,22 @@
<?php
/**
* @file
* Contains \Drupal\edit\Access\EditEntityFieldAccessCheckInterface.
*/
namespace Drupal\edit\Access;
use Drupal\Core\Entity\EntityInterface;
/**
* Access check for editing entity fields.
*/
interface EditEntityFieldAccessCheckInterface {
/**
* Checks access to edit the requested field of the requested entity.
*/
public function accessEditEntityField(EntityInterface $entity, $field_name);
}

View File

@ -0,0 +1,52 @@
<?php
/**
* @file
* Definition of Drupal\Edit\Ajax\BaseCommand.
*/
namespace Drupal\edit\Ajax;
use Drupal\Core\Ajax\CommandInterface;
/**
* Base AJAX command that only exists simplify Edit's actual AJAX commands.
*/
class BaseCommand implements CommandInterface {
/**
* The name of the command.
*
* @var string
*/
protected $command;
/**
* The data to pass on to the client side.
*
* @var string
*/
protected $data;
/**
* Constructs a BaseCommand object.
*
* @param string $data
* The data to pass on to the client side.
*/
public function __construct($command, $data) {
$this->command = $command;
$this->data = $data;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return array(
'command' => $this->command,
'data' => $this->data,
);
}
}

View File

@ -0,0 +1,27 @@
<?php
/**
* @file
* Definition of Drupal\edit\Ajax\FieldFormCommand.
*/
namespace Drupal\edit\Ajax;
use Drupal\Core\Ajax\CommandInterface;
/**
* AJAX command for passing a rendered field form to Edit's JavaScript app.
*/
class FieldFormCommand extends BaseCommand {
/**
* Constructs a FieldFormCommand object.
*
* @param string $data
* The data to pass on to the client side.
*/
public function __construct($data) {
parent::__construct('editFieldForm', $data);
}
}

View File

@ -0,0 +1,28 @@
<?php
/**
* @file
* Definition of Drupal\edit\Ajax\FieldFormSavedCommand.
*/
namespace Drupal\edit\Ajax;
use Drupal\Core\Ajax\CommandInterface;
/**
* AJAX command to indicate a field form was saved without validation errors and
* pass the rerendered field to Edit's JavaScript app.
*/
class FieldFormSavedCommand extends BaseCommand {
/**
* Constructs a FieldFormSavedCommand object.
*
* @param string $data
* The data to pass on to the client side.
*/
public function __construct($data) {
parent::__construct('editFieldFormSaved', $data);
}
}

View File

@ -0,0 +1,28 @@
<?php
/**
* @file
* Definition of Drupal\edit\Ajax\FieldFormValidationErrorsCommand.
*/
namespace Drupal\edit\Ajax;
use Drupal\Core\Ajax\CommandInterface;
/**
* AJAX command to indicate a field form was attempted to be saved but failed
* validation and pass the validation errors.
*/
class FieldFormValidationErrorsCommand extends BaseCommand {
/**
* Constructs a FieldFormValidationErrorsCommand object.
*
* @param string $data
* The data to pass on to the client side.
*/
public function __construct($data) {
parent::__construct('editFieldFormValidationErrors', $data);
}
}

View File

@ -0,0 +1,28 @@
<?php
/**
* @file
* Definition of Drupal\edit\Ajax\FieldRenderedWithoutTransformationFiltersCommand.
*/
namespace Drupal\edit\Ajax;
use Drupal\Core\Ajax\CommandInterface;
/**
* AJAX command to rerender a processed text field without any transformation
* filters.
*/
class FieldRenderedWithoutTransformationFiltersCommand extends BaseCommand {
/**
* Constructs a FieldRenderedWithoutTransformationFiltersCommand object.
*
* @param string $data
* The data to pass on to the client side.
*/
public function __construct($data) {
parent::__construct('editFieldRenderedWithoutTransformationFilters', $data);
}
}

View File

@ -0,0 +1,37 @@
<?php
/**
* @file
* Contains \Drupal\edit\EditBundle.
*/
namespace Drupal\edit;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\Bundle\Bundle;
/**
* Edit dependency injection container.
*/
class EditBundle extends Bundle {
/**
* Overrides Symfony\Component\HttpKernel\Bundle\Bundle::build().
*/
public function build(ContainerBuilder $container) {
// Register the plugin managers for our plugin types with the dependency injection container.
$container->register('plugin.manager.edit.processed_text_editor', 'Drupal\edit\Plugin\ProcessedTextEditorManager');
$container->register('access_check.edit.entity_field', 'Drupal\edit\Access\EditEntityFieldAccessCheck')
->addTag('access_check');
$container->register('edit.editor.selector', 'Drupal\edit\EditorSelector')
->addArgument(new Reference('plugin.manager.edit.processed_text_editor'));
$container->register('edit.metadata.generator', 'Drupal\edit\MetadataGenerator')
->addArgument(new Reference('access_check.edit.entity_field'))
->addArgument(new Reference('edit.editor.selector'));
}
}

View File

@ -0,0 +1,147 @@
<?php
/**
* @file
* Contains of \Drupal\edit\EditController.
*/
namespace Drupal\edit;
use Symfony\Component\DependencyInjection\ContainerAware;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Entity\EntityInterface;
use Drupal\edit\Ajax\FieldFormCommand;
use Drupal\edit\Ajax\FieldFormSavedCommand;
use Drupal\edit\Ajax\FieldFormValidationErrorsCommand;
use Drupal\edit\Ajax\FieldRenderedWithoutTransformationFiltersCommand;
/**
* Returns responses for Edit module routes.
*/
class EditController extends ContainerAware {
/**
* Returns the metadata for a set of fields.
*
* Given a list of field edit IDs as POST parameters, run access checks on the
* entity and field level to determine whether the current user may edit them.
* Also retrieves other metadata.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response.
*/
public function metadata(Request $request) {
$fields = $request->request->get('fields');
if (!isset($fields)) {
throw new NotFoundHttpException();
}
$metadataGenerator = $this->container->get('edit.metadata.generator');
$metadata = array();
foreach ($fields as $field) {
list($entity_type, $entity_id, $field_name, $langcode, $view_mode) = explode(':', $field);
// Load the entity.
if (!$entity_type || !entity_get_info($entity_type)) {
throw new NotFoundHttpException();
}
$entity = entity_load($entity_type, $entity_id);
if (!$entity) {
throw new NotFoundHttpException();
}
// Validate the field name and language.
if (!$field_name || !($instance = field_info_instance($entity->entityType(), $field_name, $entity->bundle()))) {
throw new NotFoundHttpException();
}
if (!$langcode || (field_valid_language($langcode) !== $langcode)) {
throw new NotFoundHttpException();
}
$metadata[$field] = $metadataGenerator->generate($entity, $instance, $langcode, $view_mode);
}
return new JsonResponse($metadata);
}
/**
* Returns a single field edit form as an Ajax response.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being edited.
* @param string $field_name
* The name of the field that is being edited.
* @param string $langcode
* The name of the language for which the field is being edited.
* @param string $view_mode
* The view mode the field should be rerendered in.
* @return \Drupal\Core\Ajax\AjaxResponse
* The Ajax response.
*/
public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view_mode) {
$response = new AjaxResponse();
$form_state = array(
'langcode' => $langcode,
'no_redirect' => TRUE,
'build_info' => array('args' => array($entity, $field_name)),
);
$form = drupal_build_form('edit_field_form', $form_state);
if (!empty($form_state['executed'])) {
// The form submission took care of saving the updated entity. Return the
// updated view of the field.
$entity = $form_state['entity'];
$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;
}
/**
* Returns an Ajax response to render a text field without transformation filters.
*
* @param int $entity
* 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 \Drupal\Core\Ajax\AjaxResponse
* The Ajax response.
*/
public function getUntransformedText(EntityInterface $entity, $field_name, $langcode, $view_mode) {
$response = new AjaxResponse();
$output = field_view_field($entity->entityType(), $entity, $field_name, $view_mode, $langcode);
$langcode = $output['#language'];
// Direct text editing is only supported for single-valued fields.
$editable_text = check_markup($output['#items'][0]['value'], $output['#items'][0]['format'], $langcode, FALSE, array(FILTER_TYPE_TRANSFORM_REVERSIBLE, FILTER_TYPE_TRANSFORM_IRREVERSIBLE));
$response->addCommand(new FieldRenderedWithoutTransformationFiltersCommand($editable_text));
return $response;
}
}

View File

@ -0,0 +1,167 @@
<?php
/**
* @file
* Contains \Drupal\edit\EditorSelector.
*/
namespace Drupal\edit;
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\field\FieldInstance;
/**
* Selects an in-place editor for a given entity field.
*/
class EditorSelector implements EditorSelectorInterface {
/**
* The manager for processed text editor plugins.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $processedTextEditorManager;
/**
* The processed text editor plugin selected.
*
* @var \Drupal\edit\Plugin\ProcessedTextEditorInterface
*/
protected $processedTextEditorPlugin;
/**
* Constructs a new EditorSelector.
*
* @param \Drupal\Component\Plugin\PluginManagerInterface $processed_text_editor_manager
* The manager for processed text editor plugins.
*/
public function __construct(PluginManagerInterface $processed_text_editor_manager) {
$this->processedTextEditorManager = $processed_text_editor_manager;
}
/**
* Implements \Drupal\edit\EditorSelectorInterface::getEditor().
*/
public function getEditor($formatter_type, FieldInstance $instance, array $items) {
// Check if the formatter defines an appropriate in-place editor. For
// example, text formatters displaying untrimmed text can choose to use the
// 'direct' editor. If the formatter doesn't specify, fall back to the
// 'form' editor, since that can work for any field. Formatter definitions
// can use 'disabled' to explicitly opt out of in-place editing.
$formatter_info = field_info_formatter_types($formatter_type);
$editor = isset($formatter_info['edit']['editor']) ? $formatter_info['edit']['editor'] : 'form';
if ($editor == 'disabled') {
return;
}
// The same text formatters can be used for single-valued and multivalued
// fields and for processed and unprocessed text, so we can't rely on the
// formatter definition for the final determination, because:
// - The direct editor does not work for multivalued fields.
// - Processed text can benefit from a WYSIWYG editor.
// - Empty processed text without an already selected format requires a form
// to select one.
// @todo The processed text logic is too coupled to text fields. Figure out
// how to generalize to other textual field types.
// @todo All of this might hint at formatter *definitions* not being the
// ideal place for editor specification. Moving the determination to
// something that works with instantiated formatters, not just their
// definitions, could alleviate that, but might come with its own
// challenges.
if ($editor == 'direct') {
$field = field_info_field($instance['field_name']);
if ($field['cardinality'] != 1) {
// The direct editor does not work for multivalued fields.
$editor = 'form';
}
elseif (!empty($instance['settings']['text_processing'])) {
$format_id = $items[0]['format'];
if (isset($format_id)) {
$wysiwyg_plugin = $this->getProcessedTextEditorPlugin();
if (isset($wysiwyg_plugin) && $wysiwyg_plugin->checkFormatCompatibility($format_id)) {
// Yay! Even though the text is processed, there's a WYSIWYG editor
// that can work with it.
$editor = 'direct-with-wysiwyg';
}
else {
// @todo We might not have to downgrade all the way to 'form'. The
// 'direct' editor might be appropriate for some kinds of
// processed text.
$editor = 'form';
}
}
else {
// If a format is not yet selected, a form is needed to select one.
$editor = 'form';
}
}
}
return $editor;
}
/**
* Implements \Drupal\edit\EditorSelectorInterface::getAllEditorAttachments().
*/
public function getAllEditorAttachments() {
$this->getProcessedTextEditorPlugin();
if (!isset($this->processedTextEditorPlugin)) {
return array();
}
$js = array();
// Add library and settings for the selected processed text editor plugin.
$definition = $this->processedTextEditorPlugin->getDefinition();
if (!empty($definition['library'])) {
$js['library'][] = array($definition['library']['module'], $definition['library']['name']);
}
$this->processedTextEditorPlugin->addJsSettings();
// Also add the setting to register it with Create.js
if (!empty($definition['propertyEditorName'])) {
$js['js'][] = array(
'data' => array(
'edit' => array(
'wysiwygEditorWidgetName' => $definition['propertyEditorName'],
),
),
'type' => 'setting'
);
}
return $js;
}
/**
* Returns the plugin to use for the 'direct-with-wysiwyg' editor.
*
* @return \Drupal\edit\Plugin\ProcessedTextEditorInterface
* The editor plugin.
*
* @todo We currently only support one plugin (the first one returned by the
* manager) for the 'direct-with-wysiwyg' editor on any given page. Enhance
* this to allow different ones per element (e.g., Aloha for one text field
* and CKEditor for another one).
*
* @todo The terminology here is confusing. 'direct-with-wysiwyg' is one of
* several possible "editor"s for processed text. When using it, we need to
* integrate a particular WYSIWYG editor, which in Create.js is called a
* "PropertyEditor widget", but we're not yet including "widget" in the name
* of ProcessedTextEditorInterface to minimize confusion with Field API
* widgets. So, we're currently refering to these as "plugins", which is
* correct in that it's using Drupal's Plugin API, but less informative than
* naming it "widget" or similar.
*/
protected function getProcessedTextEditorPlugin() {
if (!isset($this->processedTextEditorPlugin)) {
$definitions = $this->processedTextEditorManager->getDefinitions();
if (count($definitions)) {
$plugin_ids = array_keys($definitions);
$plugin_id = $plugin_ids[0];
$this->processedTextEditorPlugin = $this->processedTextEditorManager->createInstance($plugin_id);
}
}
return $this->processedTextEditorPlugin;
}
}

View File

@ -0,0 +1,55 @@
<?php
/**
* @file
* Contains \Drupal\edit\EditorSelectorInterface.
*/
namespace Drupal\edit;
use Drupal\field\FieldInstance;
/**
* Interface for selecting an in-place editor for a given entity field.
*/
interface EditorSelectorInterface {
/**
* Returns the in-place editor to use for a given field instance.
*
* The Edit module includes three in-place 'editors' that integrate with the
* Create.js framework:
* - direct: A minimal wrapper to simply setting the HTML5 contenteditable
* attribute on the DOM element.
* - direct-with-wysiwyg: Binds a complete WYSIWYG editor (such as Aloha) to
* the DOM element.
* - form: Fetches a simplified version of the field's edit form (widget) and
* overlays that over the DOM element.
*
* These three editors are registered in js/createjs/editable.js. Modules may
* register additional editors via the Create.js API.
*
* This function returns the editor to use for a given field instance.
*
* @param string $formatter_type
* The field's formatter type name.
* @param \Drupal\field\FieldInstance $instance
* The field's instance info.
* @param array $items
* The field's item values.
*
* @return string|NULL
* The editor to use, or NULL to not enable in-place editing.
*/
public function getEditor($formatter_type, FieldInstance $instance, array $items);
/**
* Returns the attachments for all editors.
*
* @return array
* An array of attachments, for use with #attached.
*
* @see drupal_process_attached()
*/
public function getAllEditorAttachments();
}

View File

@ -0,0 +1,142 @@
<?php
/**
* @file
* Contains \Drupal\edit\Form\EditFieldForm.
*/
namespace Drupal\edit\Form;
use Drupal\Core\Entity\EntityInterface;
/**
* Builds and process a form for editing a single entity field.
*/
class EditFieldForm {
/**
* Builds a form for a single entity field.
*/
public function build(array $form, array &$form_state, EntityInterface $entity, $field_name) {
if (!isset($form_state['entity'])) {
$this->init($form_state, $entity, $field_name);
}
// Add the field form.
field_attach_form($form_state['entity']->entityType(), $form_state['entity'], $form, $form_state, $form_state['langcode'], array('field_name' => $form_state['field_name']));
// Add a submit button. Give it a class for easy JavaScript targeting.
$form['actions'] = array('#type' => 'actions');
$form['actions']['submit'] = array(
'#type' => 'submit',
'#value' => t('Save'),
'#attributes' => array('class' => array('edit-form-submit')),
);
// Add validation and submission handlers.
$form['#validate'][] = array($this, 'validate');
$form['#submit'][] = array($this, 'submit');
// Simplify it for optimal in-place use.
$this->simplify($form, $form_state);
return $form;
}
/**
* Initialize the form state and the entity before the first form build.
*/
protected function init(array &$form_state, EntityInterface $entity, $field_name) {
// @todo Rather than special-casing $node->revision, invoke prepareEdit()
// once http://drupal.org/node/1863258 lands.
if ($entity->entityType() == 'node') {
$entity->setNewRevision(in_array('revision', variable_get('node_options_' . $entity->bundle(), array())));
$entity->log = NULL;
}
$form_state['entity'] = $entity;
$form_state['field_name'] = $field_name;
}
/**
* Validates the form.
*/
public function validate(array $form, array &$form_state) {
$entity = $this->buildEntity($form, $form_state);
field_attach_form_validate($entity->entityType(), $entity, $form, $form_state, array('field_name' => $form_state['field_name']));
}
/**
* Saves the entity with updated values for the edited field.
*/
public function submit(array $form, array &$form_state) {
$form_state['entity'] = $this->buildEntity($form, $form_state);
$form_state['entity']->save();
}
/**
* Returns a cloned entity containing updated field values.
*
* Calling code may then validate the returned entity, and if valid, transfer
* it back to the form state and save it.
*/
protected function buildEntity(array $form, array &$form_state) {
$entity = clone $form_state['entity'];
// @todo field_attach_submit() only "submits" to the in-memory $entity
// object, not to anywhere persistent. Consider renaming it to minimize
// confusion: http://drupal.org/node/1846648.
field_attach_submit($entity->entityType(), $entity, $form, $form_state, array('field_name' => $form_state['field_name']));
// @todo Refine automated log messages and abstract them to all entity
// types: http://drupal.org/node/1678002.
if ($entity->entityType() == 'node' && $entity->isNewRevision() && !isset($entity->log)) {
$instance = field_info_instance($entity->entityType(), $form_state['field_name'], $entity->bundle());
$entity->log = t('Updated the %field-name field through in-place editing.', array('%field-name' => $instance['label']));
}
return $entity;
}
/**
* Simplifies the field edit form for in-place editing.
*
* This function:
* - Hides the field label inside the form, because JavaScript displays it
* outside the form.
* - Adjusts textarea elements to fit their content.
*
* @param array $form
* An associative array containing the structure of the form.
*/
protected function simplify(array &$form, array &$form_state) {
$field_name = $form_state['field_name'];
$langcode = $form_state['langcode'];
$widget_element =& $form[$field_name][$langcode];
// Hide the field label from displaying within the form, because JavaScript
// displays the equivalent label that was provided within an HTML data
// attribute of the field's display element outside of the form. Do this for
// widgets without child elements (like Option widgets) as well as for ones
// with per-delta elements. Skip single checkboxes, because their title is
// key to their UI. Also skip widgets with multiple subelements, because in
// that case, per-element labeling is informative.
$num_children = count(element_children($widget_element));
if ($num_children == 0 && $widget_element['#type'] != 'checkbox') {
$widget_element['#title_display'] = 'invisible';
}
if ($num_children == 1 && isset($widget_element[0]['value'])) {
// @todo While most widgets name their primary element 'value', not all
// do, so generalize this.
$widget_element[0]['value']['#title_display'] = 'invisible';
}
// Adjust textarea elements to fit their content.
if (isset($widget_element[0]['value']['#type']) && $widget_element[0]['value']['#type'] == 'textarea') {
$lines = count(explode("\n", $widget_element[0]['value']['#default_value']));
$widget_element[0]['value']['#rows'] = $lines + 1;
}
}
}

View File

@ -0,0 +1,87 @@
<?php
/**
* @file
* Contains \Drupal\edit\MetadataGenerator.
*/
namespace Drupal\edit;
use Drupal\Core\Entity\EntityInterface;
use Drupal\field\FieldInstance;
use Drupal\edit\Access\EditEntityFieldAccessCheckInterface;
/**
* Generates in-place editing metadata for an entity field.
*/
class MetadataGenerator implements MetadataGeneratorInterface {
/**
* An object that checks if a user has access to edit a given entity field.
*
* @var \Drupal\edit\Access\EditEntityFieldAccessCheckInterface
*/
protected $accessChecker;
/**
* An object that determines which editor to attach to a given field.
*
* @var \Drupal\edit\EditorSelectorInterface
*/
protected $editorSelector;
/**
* Constructs a new MetadataGenerator.
*
* @param \Drupal\edit\Access\EditEntityFieldAccessCheckInterface $access_checker
* An object that checks if a user has access to edit a given field.
* @param \Drupal\edit\EditorSelectorInterface $editor_selector
* An object that determines which editor to attach to a given field.
*/
public function __construct(EditEntityFieldAccessCheckInterface $access_checker, EditorSelectorInterface $editor_selector) {
$this->accessChecker = $access_checker;
$this->editorSelector = $editor_selector;
}
/**
* Implements \Drupal\edit\MetadataGeneratorInterface::generate().
*/
public function generate(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode) {
$field_name = $instance['field_name'];
// Early-return if user does not have access.
$access = $this->accessChecker->accessEditEntityField($entity, $field_name);
if (!$access) {
return array('access' => FALSE);
}
$label = $instance['label'];
$formatter_id = $instance->getFormatter($view_mode)->getPluginId();
$items = $entity->get($field_name);
$items = $items[$langcode];
$editor = $this->editorSelector->getEditor($formatter_id, $instance, $items);
$metadata = array(
'label' => $label,
'access' => TRUE,
'editor' => $editor,
'aria' => t('Entity @type @id, field @field', array('@type' => $entity->entityType(), '@id' => $entity->id(), '@field' => $label)),
);
// Additional metadata for WYSIWYG editor integration.
if ($editor === 'direct-with-wysiwyg') {
$format_id = $items[0]['format'];
$metadata['format'] = $format_id;
$metadata['formatHasTransformations'] = $this->textFormatHasTransformationFilters($format_id);
}
return $metadata;
}
/**
* Returns whether the text format has transformation filters.
*/
protected function textFormatHasTransformationFilters($format_id) {
return (bool) count(array_intersect(array(FILTER_TYPE_TRANSFORM_REVERSIBLE, FILTER_TYPE_TRANSFORM_IRREVERSIBLE), filter_get_filter_types_by_format($format_id)));
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* @file
* Contains \Drupal\edit\MetadataGeneratorInterface.
*/
namespace Drupal\edit;
use Drupal\Core\Entity\EntityInterface;
use Drupal\field\FieldInstance;
/**
* Interface for generating in-place editing metadata for an entity field.
*/
interface MetadataGeneratorInterface {
/**
* Generates in-place editing metadata for an entity field.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being edited.
* @param Drupal\field\FieldInstance $instance
* The field instance of the field being edited.
* @param string $langcode
* The name of the language for which the field is being edited.
* @param string $view_mode
* The view mode the field should be rerendered in.
* @return array
* An array containing metadata with the following keys:
* - label: the user-visible label for the field.
* - access: whether the current user may edit the field or not.
* - editor: which editor should be used for the field.
* - aria: the ARIA label.
* - format: (optional) the text format ID of the field.
* - formatHasTransformations: (optional) whether the text format uses any
* transformation filters or not.
*/
public function generate(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode);
}

View File

@ -0,0 +1,26 @@
<?php
/**
* @file
* Definition of \Drupal\edit\Plugin\ProcessedTextPropertyBase.
*/
namespace Drupal\edit\Plugin;
use Drupal\Component\Plugin\PluginBase;
/**
* Base class for processed text editor plugins.
*/
abstract class ProcessedTextEditorBase extends PluginBase implements ProcessedTextEditorInterface {
/**
* Implements \Drupal\edit\Plugin\ProcessedTextEditorInterface::addJsSettings().
*
* This base class provides an empty implementation for text editors that
* do not need to add JavaScript settings besides those added by the library.
*/
public function addJsSettings() {
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* @file
* Definition of \Drupal\edit\Plugin\ProcessedTextEditorInterface.
*/
namespace Drupal\edit\Plugin;
use Drupal\Component\Plugin\PluginInspectionInterface;
/**
* Defines an interface for PropertyEditor widgets for processed text fields.
*
* A PropertyEditor widget is a user-facing interface to edit an entity property
* through Create.js.
*/
interface ProcessedTextEditorInterface extends PluginInspectionInterface {
/**
* Adds JavaScript settings.
*/
public function addJsSettings();
/**
* Checks if the text editor is compatible with a given text format.
*
* @param $format_id
* A text format ID.
*
* @return bool
* TRUE if it is compatible, FALSE otherwise.
*/
public function checkFormatCompatibility($format_id);
}

View File

@ -0,0 +1,31 @@
<?php
/**
* @file
* Definition of \Drupal\edit\Plugin\ProcessedTextEditorManager.
*/
namespace Drupal\edit\Plugin;
use Drupal\Component\Plugin\PluginManagerBase;
use Drupal\Component\Plugin\Factory\DefaultFactory;
use Drupal\Core\Plugin\Discovery\AlterDecorator;
use Drupal\Core\Plugin\Discovery\AnnotatedClassDiscovery;
use Drupal\Core\Plugin\Discovery\CacheDecorator;
/**
* ProcessedTextEditor manager.
*/
class ProcessedTextEditorManager extends PluginManagerBase {
/**
* Overrides \Drupal\Component\Plugin\PluginManagerBase::__construct().
*/
public function __construct() {
$this->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);
}
}

View File

@ -0,0 +1,240 @@
<?php
/**
* @file
* Definition of Drupal\edit\Tests\EditorSelectionTest.
*/
namespace Drupal\edit\Tests;
use Drupal\simpletest\DrupalUnitTestBase;
use Drupal\edit\Plugin\ProcessedTextEditorManager;
use Drupal\edit\EditorSelector;
/**
* Test in-place field editor selection.
*/
class EditorSelectionTest extends DrupalUnitTestBase {
var $default_storage = 'field_sql_storage';
/**
* The editor selector object to be tested.
*
* @var \Drupal\edit\EditorSelectorInterface
*/
protected $editorSelector;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array('system', 'field_test', 'field', 'number', 'text', 'edit', 'edit_test');
public static function getInfo() {
return array(
'name' => '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);
// @todo Rather than using the real ProcessedTextEditorManager, which can
// find all text editor plugins in the codebase, create a mock one for
// testing that is populated with only the ones we want to test.
$text_editor_manager = new ProcessedTextEditorManager();
$this->editorSelector = new EditorSelector($text_editor_manager);
}
/**
* 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 $this->editorSelector->getEditor($field_instance['display'][$display]['type'], $field_instance, $items);
}
/**
* 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.");
}
}

View File

@ -0,0 +1,6 @@
name = Edit test
description = Support module for the Edit module tests.
core = 8.x
package = Testing
version = VERSION
hidden = TRUE

View File

@ -0,0 +1,6 @@
<?php
/**
* @file
* Helper module for the Edit tests.
*/

View File

@ -0,0 +1,31 @@
<?php
/**
* @file
* Definition of Drupal\edit_test\Plugin\edit\processed_text_editor\TestProcessedEditor.
*/
namespace Drupal\edit_test\Plugin\edit\processed_text_editor;
use Drupal\edit\Plugin\ProcessedTextEditorBase;
use Drupal\Core\Annotation\Plugin;
use Drupal\Core\Annotation\Translation;
/**
* Defines a test processed text editor plugin.
*
* @Plugin(
* id = "test_processed_editor",
* title = @Translation("Test Processed Editor")
* )
*/
class TestProcessedEditor extends ProcessedTextEditorBase {
/**
* Implements Drupal\edit\Plugin\ProcessedTextEditorInterface::checkFormatCompatibility().
*/
function checkFormatCompatibility($format_id) {
return state()->get('edit_test.compatible_format') == $format_id;
}
}

View File

@ -23,6 +23,9 @@ use Drupal\Core\Entity\EntityInterface;
* "text",
* "text_long",
* "text_with_summary"
* },
* edit = {
* "editor" = "direct"
* }
* )
*/

View File

@ -23,6 +23,9 @@ use Drupal\Core\Entity\EntityInterface;
* "text",
* "text_long",
* "text_with_summary"
* },
* edit = {
* "editor" = "direct"
* }
* )
*/

View File

@ -22,6 +22,9 @@ use Drupal\Core\Annotation\Translation;
* },
* settings = {
* "trim_length" = "600"
* },
* edit = {
* "editor" = "form"
* }
* )
*/

View File

@ -31,6 +31,9 @@ use Drupal\Core\Entity\EntityInterface;
* },
* settings = {
* "trim_length" = "600"
* },
* edit = {
* "editor" = "form"
* }
* )
*/

View File

@ -11,6 +11,7 @@ dependencies[] = config
dependencies[] = comment
dependencies[] = contextual
dependencies[] = contact
dependencies[] = edit
dependencies[] = help
dependencies[] = image
dependencies[] = menu