From 35b2910b37bb20deeb341589f27ba49a279599b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?"J.=20Rene=CC=81e=20Beach"?= Date: Sat, 8 Jun 2013 01:02:47 -0400 Subject: [PATCH] Issue #1678002-134 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: J. Renée Beach --- core/modules/edit/css/edit.icons-rtl.css | 3 + core/modules/edit/css/edit.icons.css | 46 +++ core/modules/edit/css/edit.module-rtl.css | 3 + core/modules/edit/css/edit.module.css | 336 ++---------------- core/modules/edit/css/edit.theme-rtl.css | 3 + core/modules/edit/css/edit.theme.css | 224 ++++++++++++ core/modules/edit/edit.module | 6 + core/modules/edit/images/close.png | 4 - core/modules/edit/images/icon-close.png | 3 + core/modules/edit/images/icon-throbber.gif | 6 + core/modules/edit/images/throbber.gif | 6 - core/modules/edit/js/edit.js | 35 +- core/modules/edit/js/editors/directEditor.js | 4 +- core/modules/edit/js/editors/formEditor.js | 17 +- core/modules/edit/js/models/EntityModel.js | 357 +++++++++++++++++++- core/modules/edit/js/theme.js | 48 ++- core/modules/edit/js/views/AppView.js | 181 +++++++--- core/modules/edit/js/views/ContextualLinkView.js | 28 +- core/modules/edit/js/views/EditorDecorationView.js | 68 ++-- core/modules/edit/js/views/EditorView.js | 25 +- core/modules/edit/js/views/EntityToolbarView.js | 315 +++++++++++++++++ core/modules/edit/js/views/EntityView.js | 31 ++ core/modules/edit/js/views/FieldToolbarView.js | 281 ++------------- .../edit/lib/Drupal/edit/EditController.php | 10 +- .../edit/lib/Drupal/edit/MetadataGenerator.php | 13 +- .../lib/Drupal/edit/MetadataGeneratorInterface.php | 17 +- .../editor/js/editor.formattedTextEditor.js | 4 +- 27 files changed, 1367 insertions(+), 707 deletions(-) create mode 100644 core/modules/edit/css/edit.icons-rtl.css create mode 100644 core/modules/edit/css/edit.icons.css create mode 100644 core/modules/edit/css/edit.module-rtl.css create mode 100644 core/modules/edit/css/edit.theme-rtl.css create mode 100644 core/modules/edit/css/edit.theme.css delete mode 100644 core/modules/edit/images/close.png create mode 100644 core/modules/edit/images/icon-close.png create mode 100644 core/modules/edit/images/icon-throbber.gif delete mode 100644 core/modules/edit/images/throbber.gif create mode 100644 core/modules/edit/js/views/EntityToolbarView.js create mode 100644 core/modules/edit/js/views/EntityView.js diff --git a/core/modules/edit/css/edit.icons-rtl.css b/core/modules/edit/css/edit.icons-rtl.css new file mode 100644 index 0000000..7c8de15 --- /dev/null +++ b/core/modules/edit/css/edit.icons-rtl.css @@ -0,0 +1,3 @@ +/** + * @file edit.icons-rtl.css + */ diff --git a/core/modules/edit/css/edit.icons.css b/core/modules/edit/css/edit.icons.css new file mode 100644 index 0000000..adab959 --- /dev/null +++ b/core/modules/edit/css/edit.icons.css @@ -0,0 +1,46 @@ +/** + * @file edit.icons.css + */ + +.edit .icon { + min-height: 1em; + min-width: 2.5em; + position: relative; +} +.edit .icon.icon-only { + text-indent: -9999px; /* LTR */ +} +.edit .icon.icon-end { + padding-right: 2.5em; /* LTR */ +} +.edit .icon:before { + background-attachment: scroll; + background-color: transparent; + background-position: center center; + background-repeat: no-repeat; + content: ''; + display: block; + height: 100%; + left: 0; /* LTR */ + position: absolute; + top: 0; + width: 100%; +} +.edit .icon-end:before { + left: auto; /* LTR */ + right: 0.5em; /* LTR */ + width: 18px; +} +.edit button.icon { + font-size: 1em; +} + +/** + * Images. + */ +.edit .icon-close:before { + background-image: url("../images/icon-close.png"); +} +.edit .icon-throbber:before { + background-image: url("../images/icon-throbber.gif"); +} diff --git a/core/modules/edit/css/edit.module-rtl.css b/core/modules/edit/css/edit.module-rtl.css new file mode 100644 index 0000000..d05c000 --- /dev/null +++ b/core/modules/edit/css/edit.module-rtl.css @@ -0,0 +1,3 @@ +/** + * @file edit.module-rtl.css + */ diff --git a/core/modules/edit/css/edit.module.css b/core/modules/edit/css/edit.module.css index 6a5ac83..7af5aee 100644 --- a/core/modules/edit/css/edit.module.css +++ b/core/modules/edit/css/edit.module.css @@ -1,85 +1,13 @@ /** - * 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; -} - - - - -/** - * Candidate editables + editables being edited. + * @file edit.module.css * * Note: every class is prefixed with "edit-" to prevent collisions with modules * or themes. In IPE-specific DOM subtrees, this is not necessary. */ -/* Editable. */ +/** + * Editable. + */ .edit-editable { z-index: 300; position: relative; @@ -87,43 +15,23 @@ .edit-editable:focus { outline: none; } -.edit-field.edit-editable, -.edit-field .edit-editable { - box-shadow: 0 0 1px 1px #4d9de9; -} -/* Highlighted (hovered) editable. */ +/** + * Highlighted (hovered) editable. + */ .edit-editable.edit-highlighted { z-index: 305; - min-width: 200px; -} -.edit-field.edit-editable.edit-highlighted, -.edit-form.edit-editable.edit-highlighted, -.edit-field .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-editable.edit-highlighted.edit-validation-error { - box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5); +.edit-validation-errors > .messages { + margin-left: 0; + margin-right: 0; } -.edit-form.edit-editable .form-item .error { - border: 1px solid #eea0a0; -} - - -/* Editing (focused) editable. */ -.edit-form.edit-editable.edit-editing, -.edit-field .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-validation-errors > .messages > ul { + list-style: none; + margin: 0; + padding: 0; } - - - /** * Edit mode: modal. */ @@ -131,30 +39,9 @@ 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; + left: 40%; /* LTR */ } - - - /** * Edit mode: type=direct. */ @@ -162,59 +49,51 @@ z-index: 300; position: relative; } - .edit-validation-errors .messages.error { position: absolute; top: 6px; - left: -5px; + left: -5px; /* LTR */ 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; - background-color: white; } - .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 + * Default form styling overrides. */ +.edit-form .form-wrapper .form-wrapper { + margin: inherit; +} +.edit-form .form-actions { + display: none; +} +.edit-form input { + max-width: 100%; +} -/* Trick: wrap statically positioned elements in relatively positioned element - without changing its location. This allows us to absolutely position the - toolbar. -*/ -.edit-toolbar-container, +/** + * Entity toolbar. + */ +.edit-toolbar-container { + max-width: 100%; + position: absolute; + width: 40em; + z-index: 350; +} .edit-form-container { position: relative; padding: 0; @@ -223,150 +102,9 @@ 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; - display: none; -} -.edit-highlighted .edit-toolbar-heightfaker { - display: block; -} - -/* 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 the toolbar is instructed to be "full width". */ -.edit-toolbar-fullwidth .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-floated { - float: right; -} -.edit-toolgroup.wysiwyg-main { - 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); +.edit-toolbar-label { + overflow: hidden; } diff --git a/core/modules/edit/css/edit.theme-rtl.css b/core/modules/edit/css/edit.theme-rtl.css new file mode 100644 index 0000000..c26378e --- /dev/null +++ b/core/modules/edit/css/edit.theme-rtl.css @@ -0,0 +1,3 @@ +/** + * @file edit.theme-rtl.css + */ diff --git a/core/modules/edit/css/edit.theme.css b/core/modules/edit/css/edit.theme.css new file mode 100644 index 0000000..d286925 --- /dev/null +++ b/core/modules/edit/css/edit.theme.css @@ -0,0 +1,224 @@ +/** + * @file edit.theme.css + */ + +/** + * Editable. + */ +.edit-field.edit-editable, +.edit-field .edit-editable { + box-shadow: 0 0 1px 2px #4d9de9; +} + +/** + * Highlighted (hovered) editable. + */ +.edit-field.edit-highlighted, +.edit-form.edit-highlighted, +.edit-field .edit-highlighted { + box-shadow: 0 0 1px 2px #0199ff, 0 0 3px 5px rgba(153, 153, 153, .5); +} +.edit-field.edit-changed, +.edit-form.edit-changed, +.edit-field .edit-changed { + box-shadow: 0 0 1px 2px orange, 0 0 3px 5px rgba(153, 153, 153, .5); +} +.edit-editing.edit-validation-error, +.edit-form.edit-validation-error { + box-shadow: 0 0 1px 2px red, 0 0 3px 5px rgba(153, 153, 153, .5); +} +.edit-form .form-item .error { + border: 1px solid #eea0a0; +} + +/** + * Editing (focused) editable. + */ +.edit-form.edit-editable.edit-editing, +.edit-field .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. + */ +} + +/** + * Default form styling overrides. + */ +.edit-form form { + padding: 1em; +} +.edit-form .form-item { + margin: 0; +} +.edit-form .form-wrapper { + margin: .5em; +} + +/** + * Animations. + */ +.edit-animate-invisible { + opacity: 0; +} +.edit-animate-default { + -webkit-transition: all .4s ease; + transition: all .4s ease; +} +.edit-animate-slow { + -webkit-transition: all .6s ease; + transition: all .6s ease; +} +.edit-animate-delay-veryfast { + -webkit-transition-delay: .05s; + transition-delay: .05s; +} +.edit-animate-delay-fast { + -webkit-transition-delay: .2s; + transition-delay: .2s; +} +.edit-animate-disable-width { + -webkit-transition: width 0s; + transition: width 0s; +} +.edit-animate-only-visibility { + -webkit-transition: opacity .2s ease; + transition: opacity .2s ease; +} + +/** + * Edit mode: modal. + */ +#edit_modal { + background-color: white; + border: 1px solid #0199ff; + box-shadow: 3px 3px 5px #333; + font-family: 'Droid sans', 'Lucida Grande', sans-serif; +} +#edit_modal .main { + font-size: 130%; + margin: 25px; + padding-left: 40px; /* LTR */ + background: transparent url('../images/attention.png') no-repeat; +} +#edit_modal .actions { + border-top: 1px solid #ddd; + padding: 0.25em 0.2em; + text-align: right; + background: #f5f5f5; +} + +/** + * Edit mode: type=direct. + */ +.edit-validation-errors .messages.error { + box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5); + background-color: white; +} + +/** + * Edit mode: type=form. + */ +.edit-form { + box-shadow: 0 0 30px 4px #4f4f4f; + background-color: white; +} + +/** + * Toolbars. + */ +.edit-toolbar-container { + border: 1px solid #a8a8a8; + background-color: white; + border: 1px solid #ababab; + box-shadow: 2px 2px 4px -2px black, 2px 2px 12px 0px hsla(40, 10%, 70%, 1); + -webkit-transition: all 0.5s; + transition: all 0.5s; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.edit-toolbar-label { + overflow: hidden; + padding: 0.333em 0.5em; +} +/* The toolbar; these are not necessarily visible. */ +.edit-toolbar { + font-family: 'Droid sans', 'Lucida Grande', sans-serif; +} +.edit-toolbar-entity { + padding: 0.1667em 0.2em; +} + + + /** + * Info toolgroup. + */ +.edit-toolgroup.info { + font-weight: bolder; + padding: 0 5px; + background: #fff url('../images/throbber.gif') no-repeat -60px 60px; +} +.edit-toolgroup.info.loading { + padding-right: 35px; /* LTR */ + background-position: 90% 50%; +} +.edit-toolbar-fullwidth { + width: 100%; +} +.edit-toolgroup.wysiwyg-floated { + float: right; /* LTR */ +} +.edit-toolgroup.wysiwyg-main { + clear: both; + width: 100%; + padding-left: 0; /* LTR */ +} + +/** + * Buttons. + */ +.edit-button { + background-color: #ddd; + border: 1px solid transparent; + color: #666; + cursor: pointer; + display: inline-block; + margin: 0; + opacity: 1; + padding: 0.4545em; + -webkit-transition: all .1s ease; + transition: all .1s ease; +} +.edit-button[aria-hidden="true"] { + visibility: hidden; + opacity: 0; +} +.edit-button + .edit-button { + margin-left: 0.25em; /* LTR */ +} +/* Button with icons. */ +#edit_modal .action-cancel, +.edit-toolbar .action-cancel { +} +.edit-button.action-save { + color: white; + background-color: #4e97c0; +} +.edit-button:hover, +.edit-button:active { + background-color: #e8e8e8; + border: 1px solid #cdcdcd; +} +.edit-button.action-save:hover, +.edit-button.action-save:active { + background-color: #6fc2f2; + border: 1px solid #55a5d3; +} +.edit-button.action-saving, +.edit-button.action-saving:hover, +.edit-button.action-saving:active { + background-color: #dddddd; + border-color: #ccc; +} diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module index f638954..2f75e30 100644 --- a/core/modules/edit/edit.module +++ b/core/modules/edit/edit.module @@ -82,6 +82,8 @@ function edit_library_info() { // Views. $path . '/js/views/AppView.js' => $options, $path . '/js/views/EditorDecorationView.js' => $options, + $path . '/js/views/EntityView.js' => $options, + $path . '/js/views/EntityToolbarView.js' => $options, $path . '/js/views/ContextualLinkView.js' => $options, $path . '/js/views/ModalView.js' => $options, $path . '/js/views/FieldToolbarView.js' => $options, @@ -92,14 +94,18 @@ function edit_library_info() { ), 'css' => array( $path . '/css/edit.module.css' => array(), + $path . '/css/edit.theme.css' => array(), + $path . '/css/edit.icons.css' => array(), ), 'dependencies' => array( array('system', 'jquery'), array('system', 'underscore'), array('system', 'backbone'), array('system', 'jquery.form'), + array('system', 'jquery.ui.position'), array('system', 'drupal.form'), array('system', 'drupal.ajax'), + array('system', 'drupal.debounce'), array('system', 'drupalSettings'), ), ); diff --git a/core/modules/edit/images/close.png b/core/modules/edit/images/close.png deleted file mode 100644 index e3f98b8..0000000 --- a/core/modules/edit/images/close.png +++ /dev/null @@ -1,4 +0,0 @@ -PNG - - IHDR(-S`PLTE>+tRNS```00miIDATx^=   H4ͼ!KsfQGx"LCyל׽(ux;z KA.Jo -E wy/2cdD@Ҕ L؅O%8F?QIENDB` \ No newline at end of file diff --git a/core/modules/edit/images/icon-close.png b/core/modules/edit/images/icon-close.png new file mode 100644 index 0000000..3506e56 --- /dev/null +++ b/core/modules/edit/images/icon-close.png @@ -0,0 +1,3 @@ +PNG + + IHDR Vu\tEXtSoftwareAdobe ImageReadyqe<wIDATxڔ DU=A#FALʇ1s-|ED1\ҹZlx03\&SJ;FA&iX]Mpx(tm.1=x'IWk nB$IENDB` \ No newline at end of file diff --git a/core/modules/edit/images/icon-throbber.gif b/core/modules/edit/images/icon-throbber.gif new file mode 100644 index 0000000..f2603e8 --- /dev/null +++ b/core/modules/edit/images/icon-throbber.gif @@ -0,0 +1,6 @@ +GIF89aŽ{{{! NETSCAPE2.0! ,@`)KkŏA|ad0L9~\8L Ǹ0, i{qBC~H'JRĨ`f4&a! ,` sɺ(t34M!-0,l#))9 !q(i<hB 3˥(`  ,9%cm +b0AY_e! ,dRj:ړtG8$c02P hi,ǑQ0X[LEck5`ڭP2F! a @q dfz{lQzK! ,`BjR:$BPFq(ˢ J@0i-2͇ Qck6[G`m:pQ4fqow! ,d1j}MS@S\ H9#IrPØi8f..r$ł|nl +*T\![͂l,Q0@(KFMO{ ql{! ,^I 3a!P(Ol~`8 LÇ!@$Lgx|f ,`"`Jʼn"cUP +GAw< tz! ,h9C4kk\ &I&l )F-P@ch H!ш4< +  x@R`0C"8h0BfgX(rc}Y + 1!,aйҚX]mKl[)"aĢ\*"$t& #9@1pP`,`I,8 S 8U,Q`(d `3-qH$ 1T{; \ No newline at end of file diff --git a/core/modules/edit/images/throbber.gif b/core/modules/edit/images/throbber.gif deleted file mode 100644 index f2603e8..0000000 --- a/core/modules/edit/images/throbber.gif +++ /dev/null @@ -1,6 +0,0 @@ -GIF89aŽ{{{! NETSCAPE2.0! ,@`)KkŏA|ad0L9~\8L Ǹ0, i{qBC~H'JRĨ`f4&a! ,` sɺ(t34M!-0,l#))9 !q(i<hB 3˥(`  ,9%cm -b0AY_e! ,dRj:ړtG8$c02P hi,ǑQ0X[LEck5`ڭP2F! a @q dfz{lQzK! ,`BjR:$BPFq(ˢ J@0i-2͇ Qck6[G`m:pQ4fqow! ,d1j}MS@S\ H9#IrPØi8f..r$ł|nl -*T\![͂l,Q0@(KFMO{ ql{! ,^I 3a!P(Ol~`8 LÇ!@$Lgx|f ,`"`Jʼn"cUP -GAw< tz! ,h9C4kk\ &I&l )F-P@ch H!ш4< -  x@R`0C"8h0BfgX(rc}Y - 1!,aйҚX]mKl[)"aĢ\*"$t& #9@1pP`,`I,8 S 8U,Q`(d `3-qH$ 1T{; \ No newline at end of file diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js index 102fef7..a5a9d49 100644 --- a/core/modules/edit/js/edit.js +++ b/core/modules/edit/js/edit.js @@ -22,8 +22,7 @@ var options = $.extend({ strings: { - quickEdit: Drupal.t('Quick edit'), - stopQuickEdit: Drupal.t('Stop quick edit') + quickEdit: Drupal.t('Quick edit') } }, drupalSettings.edit); @@ -57,6 +56,9 @@ Drupal.behaviors.edit = { $('body').once('edit-init', initEdit); // Process each field element: queue to be used or to fetch metadata. + // When a field is being rerender after editing, it will process + // immediately. New fields will fail to process. They are queued to have + // their metadata fetched, which occurs below in fetchMissingMetaData(). $(context).find('[data-edit-id]').once('edit').each(function (index, fieldElement) { processField(fieldElement); }); @@ -167,10 +169,15 @@ function initEdit (bodyElement) { function processField (fieldElement) { var metadata = Drupal.edit.metadata; var fieldID = fieldElement.getAttribute('data-edit-id'); + var entityID = extractEntityID(fieldID); - // Early-return if metadata for this field is mising. + // Early-return if metadata for this field is missing. if (!metadata.has(fieldID)) { - fieldsMetadataQueue.push({ el: fieldElement, fieldID: fieldID }); + fieldsMetadataQueue.push({ + el: fieldElement, + fieldID: fieldID, + entityID: entityID + }); return; } // Early-return if the user is not allowed to in-place edit this field. @@ -180,7 +187,6 @@ function processField (fieldElement) { // If an EntityModel for this field already exists (and hence also a "Quick // edit" contextual link), then initialize it immediately. - var entityID = extractEntityID(fieldID); if (Drupal.edit.collections.entities.where({ id: entityID }).length > 0) { initializeField(fieldElement, fieldID); } @@ -232,12 +238,16 @@ function fetchMissingMetadata (callback) { if (fieldsMetadataQueue.length) { var fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID'); var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el'); + var entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true); fieldsMetadataQueue = []; $.ajax({ url: Drupal.url('edit/metadata'), type: 'POST', - data: { 'fields[]' : fieldIDs }, + data: { + 'fields[]': fieldIDs, + 'entities[]': entityIDs + }, dataType: 'json', success: function(results) { // Store the metadata. @@ -344,14 +354,21 @@ function initializeEntityContextualLink (contextualLink) { return false; } // The entity for the given contextual link contains at least one field that - // the current user may edit in-place; instantiate EntityModel and + // the current user may edit in-place; instantiate EntityModel, EntityView and // ContextualLinkView. else if (hasFieldWithPermission(fieldIDs)) { var entityModel = new Drupal.edit.EntityModel({ el: contextualLink.region, - id: contextualLink.entityID + id: contextualLink.entityID, + label: Drupal.edit.metadata.get(contextualLink.entityID, 'label') }); Drupal.edit.collections.entities.add(entityModel); + // Create a view associated with the root DOM node of the entity. + var entityView = new Drupal.edit.EntityView({ + el: contextualLink.region, + model: entityModel + }); + entityModel.set('entityView', entityView); // Initialize all queued fields within this entity (creates FieldModels). _.each(fields, function (field) { @@ -408,6 +425,8 @@ function deleteContainedModelsAndQueues($context) { var contextualLinkView = entityModels[0].get('contextualLinkView'); contextualLinkView.undelegateEvents(); contextualLinkView.remove(); + // Delete the entityView. + entityModels[0].get('entityView').remove(); entityModels[0].destroy(); } diff --git a/core/modules/edit/js/editors/directEditor.js b/core/modules/edit/js/editors/directEditor.js index aafbca3..5fed240 100644 --- a/core/modules/edit/js/editors/directEditor.js +++ b/core/modules/edit/js/editors/directEditor.js @@ -46,7 +46,7 @@ Drupal.edit.editors.direct = Drupal.edit.EditorView.extend({ /** * {@inheritdoc} */ - stateChange: function (fieldModel, state) { + stateChange: function (fieldModel, state, options) { var from = fieldModel.previous('state'); var to = state; switch (to) { @@ -78,7 +78,7 @@ Drupal.edit.editors.direct = Drupal.edit.EditorView.extend({ if (from === 'invalid') { this.removeValidationErrors(); } - this.save(); + this.save(options); break; case 'saved': break; diff --git a/core/modules/edit/js/editors/formEditor.js b/core/modules/edit/js/editors/formEditor.js index e143840..92c5f06 100644 --- a/core/modules/edit/js/editors/formEditor.js +++ b/core/modules/edit/js/editors/formEditor.js @@ -14,11 +14,12 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ /** * {@inheritdoc} */ - stateChange: function (fieldModel, state) { + stateChange: function (fieldModel, state, options) { var from = fieldModel.previous('state'); var to = state; switch (to) { case 'inactive': + this.removeForm(); break; case 'candidate': if (from !== 'inactive') { @@ -38,7 +39,7 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ case 'changed': break; case 'saving': - this.save(); + this.save(options); break; case 'saved': break; @@ -82,7 +83,8 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ var formOptions = { fieldID: fieldModel.id, $el: this.$el, - nocssjs: false + nocssjs: false, + reset: Drupal.edit.app.changedFieldsInTempstore.length === 0 }; Drupal.edit.util.form.load(formOptions, function (form, ajax) { Drupal.ajax.prototype.commands.insert(ajax, { @@ -128,12 +130,13 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ /** * {@inheritdoc} */ - save: function () { + save: function (options) { var $formContainer = this.$formContainer; var $submit = $formContainer.find('.edit-form-submit'); var base = $submit.attr('id'); var editorModel = this.model; var fieldModel = this.fieldModel; + var callback = (options || {}).callback || function () {}; // Successfully saved. Drupal.ajax[base].commands.editFieldFormSaved = function (ajax, response, status) { @@ -144,7 +147,11 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ // Then, set the 'html' attribute on the field model. This will cause the // field to be rerendered. fieldModel.set('html', response.data); - }; + + // Invoke the optional callback. The callback will most likely be an + // anonymous function from AppView.enableEditor(). + callback.call(); + }; // Unsuccessfully saved; validation errors. Drupal.ajax[base].commands.editFieldFormValidationErrors = function (ajax, response, status) { diff --git a/core/modules/edit/js/models/EntityModel.js b/core/modules/edit/js/models/EntityModel.js index e484b35..8161de8 100644 --- a/core/modules/edit/js/models/EntityModel.js +++ b/core/modules/edit/js/models/EntityModel.js @@ -1,4 +1,24 @@ -(function (Backbone, Drupal) { +/** + * @todo Move the entity state change to a EntityController.js and a the field state + * change handlers to a EntityFieldController.js + * + * The possible states of an EntityModel include. + * deactivating + * closing + * closed + * launching + * opening + * opened + * editing + * committing + * quitcommitting + * + * An entity has two properties that span its states + * isActive + * isDirty + */ + +(function (_, $, Backbone, Drupal) { "use strict"; @@ -14,6 +34,8 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ el: null, // An entity ID, of the form "/", e.g. "node/1". id: null, + // The label of the entity. + label: null, // A Drupal.edit.FieldCollection for all fields of this entity. fields: null, @@ -22,7 +44,13 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ // Indicates whether this instance of this entity is currently being // edited in-place. - isActive: false + isActive: false, + // + isDirty: false, + // The current processing state of an entity. + state: 'closed', + // @see AppView.appStateChange() + entityToolbar: null }, /** @@ -30,6 +58,328 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ */ initialize: function () { this.set('fields', new Drupal.edit.FieldCollection()); + + // Respond to state changes. + this.on('change:state', this.stateChange, this); + + // The state of the entity is largely dependent on the state of its + // fields. + this.get('fields').on('change:state', this.fieldStateChange, this); + }, + + /** + * @todo We need to restrict the state progression of an entity. + * @todo We need a special exception to go from anything to deactivating. + * + * Updates FieldModels' states when an EntityModel change occurs. + * + * @param Drupal.edit.EntityModel entityModel + * This model itself. + * @param String state + * The state that this model has been set to. + * @param options + * An object of options that contains: + * - callback: (optional) A function to invoke once the state change has + * been processed. + */ + stateChange: function (entityModel, state, options) { + var to = state; + switch (to) { + // The first step in closing an entity in quick edit mode. The entity sets + // its fields' states to candidate. + case 'deactivating': + // Return the fields to candidate state. A changed field may have to go + // through confirmation first. + entityModel.get('fields').each(function (fieldModel) { + // If the field is already in the candidate state, trigger a change + // event so that the entityModel can move to the next state in + // deactivation. + if (_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) { + fieldModel.trigger('change:state', fieldModel, fieldModel.get('state'), options); + } + else { + fieldModel.set('state', 'candidate', options); + } + }); + break; + // This entity sets its fields' states to inactive. + case 'closing': + _.extend(options, { + reason: 'stop' + }); + this.get('fields').each(function (fieldModel) { + fieldModel.set('state', 'inactive', options); + }); + break; + // The entity is no longer is an editing mode. + case 'closed': + this.set('isActive', false); + this.set('isDirty', false); + break; + // Quick edit mode has been invoked on the entity. + case 'launching': + break; + // The entity sets its fields to their candidate state. + case 'opening': + // Set the fields to candidate state. + entityModel.get('fields').each(function (fieldModel) { + fieldModel.set('state', 'candidate', options); + }); + break; + // Once the entity's fields have all reached the candidate state, the + // entity transitions to the opened state. This state is a ready state. + case 'opened': + this.set('isActive', true); + break; + // When the entity toolbar save button is pressed, the entity sets its + // fields to the saving state. All of the fields must successfully save + // to temporary storage before the entity can save changes. + case 'committing': + this.get('fields').chain() + .filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], Drupal.edit.app.changedEditorStates).length; + }) + .each(function (fieldModel) { + fieldModel.set('state', 'saving', options); + }); + break; + // When quick editing is cancelled, but a field or fields contain changes + // the user is prompted to save the changes or discard them. If the user + // chooses to save the changes, the entity is put into the quitcommitting + // state. The state is set in AppView.confirmStopEditing(). + // + // @note It is possible that the callback function in the options variable + // passed to fieldModel.set() will call save on the entity too soon if + // more than one field was dirty. This needs to be investigated further. + case 'quitcommitting': + this.get('fields').chain() + .filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], Drupal.edit.app.changedEditorStates).length; + }) + .each(function (fieldModel) { + fieldModel.set('state', 'saving', options); + }); + break; + } + }, + + /** + * Reacts to state changes in this entity's fields. + * + * @param Drupal.edit.FieldModel fieldModel + * The model of the field whose state property changed. + * @param String state + * The state that this model has been set to. + * @param options + * An object of options that contains: + * - callback: (optional) A function to invoke once the state change has + * been processed. + */ + fieldStateChange: function (fieldModel, state, options) { + var entityModel = this; + var mayCommit; + // Switch on the entityModel state. + // The EntityModel responds to FieldModel state changes as a function of its + // state. For example, a field switching back to candidate state when its + // entity is in the opened state has no effect on the entity. But that same + // switch back to candidate state of a field when the entity is in the + // committing state might allow the entity to proceed with the commit flow. + switch (this.get('state')) { + // If the entity is set to closing, it must first confirm that all of its + // fieldModels have returned to the candidate state before it can start + // deactivating. + case 'deactivating': + var mayDeactivate = true; + this.get('fields').each(function (fieldModel) { + if (!_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) { + mayDeactivate = false; + } + }); + if (mayDeactivate) { + _.defer(function () { + entityModel.set('state', 'closing'); + }); + } + break; + // If the entity is set to deactivating, it must first confirm that all of + // its fieldModels have returned to inactive before it can transition to + // inactive. + case 'closing': + var mayClose = true; + this.get('fields').each(function (fieldModel) { + if (fieldModel.get('state') !== 'inactive') { + mayClose = false; + } + }); + if (mayClose) { + _.defer(function () { + entityModel.set('state', 'closed'); + }); + } + break; + // If the entity is set to opening, it must first confirm that all of + // its fieldModels have transitioned to the candidate state before it + // can declare that it is open. + case 'opening': + var mayOpen = true; + this.get('fields').each(function (fieldModel) { + if (!_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) { + mayOpen = false; + } + }); + if (mayOpen) { + _.defer(function () { + entityModel.set('state', 'opened'); + }); + } + break; + // If the entity is opened, then it is ready to accept field changes and + // to potentially commit them. + case 'opened': + var isEditing = false; + this.get('fields').each(function (fieldModel) { + // Check if the field returned to its candidate-ish state. + if (_.intersection([fieldModel.get('state')], ['active', 'invalid']).length > 0) { + isEditing = true; + } + }); + if (isEditing) { + entityModel.set('state', 'editing'); + } + // If EntityModel is in the opened state, then no field may be dirty. + entityModel.set('isDirty', false); + break; + // The entity enters the editing state when one of its fields is activated + // for editing. + case 'editing': + var isDirty = false; + this.get('fields').each(function (fieldModel) { + // Check if the field returned to its candidate-ish state. + if (_.intersection([fieldModel.get('state')], ['changed', 'invalid']).length > 0) { + isDirty = true; + } + }); + entityModel.set('isDirty', isDirty); + break; + // @note this state assumes that a user will continue editing the same entity after a save. + // @note if we just deactivate the entity on save, we can call deactivate after a save + // and eliminate the quitcommit state. + case 'committing': + // No validation errors occurred, try to commit. + mayCommit = true; + var hasInvalidField = false; + this.get('fields').each(function (fieldModel) { + // Check if the field is invalid. + if (fieldModel.get('state') === 'invalid') { + hasInvalidField = true; + } + // Check if the field returned to its candidate-ish state. + if (!_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) { + mayCommit = false; + } + }); + // If the field save returned a validation error, set the state of the + // entity back to editing. + if (hasInvalidField) { + entityModel.set('state', 'editing'); + } + // @todo, given any latency with saving to tempstore, the entity falsely commit the tempstore + // to the db. This is a potential data loss bug. + // @todo, we'll never call save if a field is invalid. + if (mayCommit) { + _.defer(function () { + entityModel.save({ + callback: function () { + entityModel.set('state', 'opened'); + } + }); + }); + } + break; + // When quick editing is cancelled, but a field or fields contain changes + // the user is prompted to save the changes or discard them. If the user + // chooses to save the changes, the entity is put into the quitcommitting + // state. The state is set in AppView.confirmStopEditing(). + // + // If the entity is in the quitcommitting state, the entity will first + // set the fields to their candidate state. Once all of the fields have + // reached the candidate or highlighted state, the entity model will call + // its save method. When the save method has processed, it will invoke + // the anonymous function callback defined here, which starts the + // deactivation process on the fields. + case 'quitcommitting': + mayCommit = true; + this.get('fields').each(function (fieldModel) { + if (!_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) { + mayCommit = false; + } + }); + if (mayCommit) { + _.defer(function () { + entityModel.save({ + // Deactivate the fields once the entity has saved. + callback: function () { + entityModel.set('state', 'deactivating'); + } + }); + }); + } + break; + default: + break; + } + }, + + /** + * Fires an AJAX request to the REST save URL for an entity. + * + * @param options + * An object of options that contains: + * - callback: (optional) A function to invoke once the state change has + * been processed. + */ + save: function (options) { + var entityModel = this; + var id = 'edit-save-entity'; + // Create a temporary element to be able to use Drupal.ajax. + var $el = $('#edit-entity-toolbar').find('.action-save'); // This is the span element inside the button. + // Create a Drupal.ajax instance to load the form. + var url = drupalSettings.basePath + 'edit/entity/' + entityModel.id; + var callback = (options || {}).callback || function () {}; + + Drupal.ajax[id] = new Drupal.ajax(id, $el, { + url: url, + event: 'edit-save.edit', + progress: { + type: 'none' + }, + error: function (response) { + // Clean up. + $el.unbind('edit-save.edit'); + Drupal.AjaxError(response, url); + } + }); + // Entity saved successfully. + Drupal.ajax[id].commands.editEntitySaved = function(ajax, response, status) { + // Remove the changed marker from all of the fields. + entityModel.get('fields').each(function (fieldModel) { + $(fieldModel.get('el')).find('.edit-editable').addBack().removeClass('edit-changed'); + }); + // Reset the list tracking changed fields. + entityModel.changedFieldsInTempstore = []; + // Clear the dirty flag on the entity. + var savedEntity = Drupal.edit.collections.entities.get(response.data.entity_type + '/' + response.data.entity_id); + if (savedEntity && 'set' in savedEntity) { + savedEntity.set('isDirty', false); + } + // Clean up. + $(ajax.element).unbind('edit-save.edit'); + + // Invoke the optional callback. + callback.call(entityModel); + }; + // We don't have handling for invalid fields. + $el.trigger('edit-save.edit'); }, /** @@ -39,6 +389,7 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ Backbone.Model.prototype.destroy.apply(this, options); // Destroy all fields of this entity. + // @todo the app should be responisble for destroying the fields. this.get('fields').each(function (fieldModel) { fieldModel.destroy(); }); @@ -58,4 +409,4 @@ Drupal.edit.EntityCollection = Backbone.Collection.extend({ model: Drupal.edit.EntityModel }); -}(Backbone, Drupal)); +}(_, jQuery, Backbone, Drupal)); diff --git a/core/modules/edit/js/theme.js b/core/modules/edit/js/theme.js index 3e6e250..0700e6f 100644 --- a/core/modules/edit/js/theme.js +++ b/core/modules/edit/js/theme.js @@ -40,23 +40,37 @@ Drupal.theme.editModal = function () { /** * Theme function for a toolbar container of the Edit module. * - * @param settings + * @param Object settings * An object with the following keys: * - String id: the id to apply to the toolbar container. * @return String * The corresponding HTML. */ -Drupal.theme.editToolbarContainer = function (settings) { +Drupal.theme.editEntityToolbar = function (settings) { var html = ''; - html += '
'; - html += '
'; - html += '
'; - html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; html += '
'; return html; }; /** + * Theme function for a toolbar container of the Edit module. + * + * @param settings + * An object with the following keys: + * - id: the id to apply to the toolbar container. + * @return + * The corresponding HTML. + */ +Drupal.theme.editFieldToolbar = function (settings) { + return '
'; +}; + +/** * Theme function for a toolbar toolgroup of the Edit module. * * @param Object settings @@ -68,9 +82,11 @@ Drupal.theme.editToolbarContainer = function (settings) { * The corresponding HTML. */ Drupal.theme.editToolgroup = function (settings) { - var classes = 'edit-toolgroup edit-animate-slow edit-animate-invisible edit-animate-delay-veryfast'; + // Classes. + var classes = (settings.classes || []); + classes.unshift('edit-toolgroup'); var html = ''; - html += '
'; + html += button.label; html += ''; } return html; diff --git a/core/modules/edit/js/views/AppView.js b/core/modules/edit/js/views/AppView.js index 6302356..53de10d 100644 --- a/core/modules/edit/js/views/AppView.js +++ b/core/modules/edit/js/views/AppView.js @@ -1,4 +1,4 @@ -(function ($, _, Backbone, Drupal) { +(function ($, _, Backbone, Drupal, drupalSettings) { "use strict"; @@ -11,6 +11,10 @@ Drupal.edit.AppView = Backbone.View.extend({ activeEditorStates: [], singleEditorStates: [], + // Ephemeral storage for changed fields that persists through field + // rerendering. + changedFieldsInTempstore: [], + /** * {@inheritdoc} * @@ -25,10 +29,11 @@ Drupal.edit.AppView = Backbone.View.extend({ // @see Drupal.edit.FieldModel.states this.activeEditorStates = ['activating', 'active']; this.singleEditorStates = ['highlighted', 'activating', 'active']; + this.changedEditorStates = ['saving', 'changed', 'invalid']; options.entitiesCollection // Track app state. - .on('change:isActive', this.appStateChange, this) + .on('change:state', this.appStateChange, this) .on('change:isActive', this.enforceSingleActiveEntity, this); options.fieldsCollection @@ -50,27 +55,40 @@ Drupal.edit.AppView = Backbone.View.extend({ * @param Boolean isActive * A boolean that represents the changed active state of the entityModel. */ - appStateChange: function (entityModel, isActive) { + appStateChange: function (entityModel, state) { var app = this; - if (isActive) { - // Move all fields of this entity from the 'inactive' state to the - // 'candidate' state. - entityModel.get('fields').each(function (fieldModel) { - // First, set up editors; they must be notified of state changes. - app.setupEditor(fieldModel); - // Second, change the field's state. - fieldModel.set('state', 'candidate'); - }); - } - else { - // Move all fields of this entity from whatever state they are in to - // the 'inactive' state. - entityModel.get('fields').each(function (fieldModel) { - // First, change the field's state. - fieldModel.set('state', 'inactive', { reason: 'stop' }); - // Second, tear down editors. - app.teardownEditor(fieldModel); - }); + var entityToolbar; + switch (state) { + case 'launching': + // Create an entity toolbar. + entityToolbar = new Drupal.edit.EntityToolbarView({ + el: entityModel.get('el'), + model: entityModel, + appModel: this.model + }); + entityModel.set('entityToolbar', entityToolbar); + // Move all fields of this entity from the 'inactive' state to the + // 'candidate' state. + entityModel.get('fields').each(function (fieldModel) { + // Set up editors; they must be notified of state changes. + app.setupEditor(fieldModel); + }); + _.defer(function () { + entityModel.set('state', 'opening'); + }); + break; + case 'closed': + entityToolbar = entityModel.get('entityToolbar'); + if (entityToolbar) { + entityModel.get('entityToolbar').remove(); + entityModel.set('entityToolbar', null); + } + // Teardown the editors. + entityModel.get('fields').each(function (fieldModel) { + // Second, tear down editors. + app.teardownEditor(fieldModel); + }); + break; } }, @@ -88,7 +106,7 @@ Drupal.edit.AppView = Backbone.View.extend({ * @param Function callback * The callback function that should receive the state acceptance result. */ - acceptEditorStateChange: function (from, to, context, callback) { + acceptEditorStateChange: function (from, to, context) { var accept = true; // If the app is in view mode, then reject all state changes except for @@ -135,15 +153,9 @@ Drupal.edit.AppView = Backbone.View.extend({ // 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 (_.indexOf(this.activeEditorStates, from) !== -1 && to === 'candidate') { if (context && context.reason === 'mouseleave') { accept = false; } @@ -165,7 +177,9 @@ Drupal.edit.AppView = Backbone.View.extend({ // that will ask the user to confirm his choice. accept = false; // The callback will be called from the helper function. - this._confirmStopEditing(callback); + this._confirmStopEditing({ + callback: (context || {}).callback || function () {} + }); } } } @@ -184,6 +198,11 @@ Drupal.edit.AppView = Backbone.View.extend({ * The field for which an in-place editor must be set up. */ setupEditor: function (fieldModel) { + // Get the corresponding entity toolbar. + var entityModel = fieldModel.get('entity'); + var entityToolbar = entityModel.get('entityToolbar'); + // Get the field toolbar DOM root from the entity toolbar. + var fieldToolbarRoot = entityToolbar.getToolbarRoot(); // Create in-place editor. var editorName = fieldModel.get('metadata').editor; var editorModel = new Drupal.edit.EditorModel(); @@ -196,9 +215,14 @@ Drupal.edit.AppView = Backbone.View.extend({ // Create in-place editor's toolbar — positions appropriately above the // edited element. var toolbarView = new Drupal.edit.FieldToolbarView({ + el: fieldToolbarRoot, model: fieldModel, $editedElement: $(editorView.getEditedElement()), - editorView: editorView + // @todo editorView is needed for the getEditUISetting method. Maybe we + // can factor out this dependency and put it in the metadata of the + // Drupal.edit.Metadata object for this field. + editorView: editorView, + entityModel: entityModel }); // Create decoration for edited element: padding if necessary, sets classes @@ -206,8 +230,7 @@ Drupal.edit.AppView = Backbone.View.extend({ var decorationView = new Drupal.edit.EditorDecorationView({ el: $(editorView.getEditedElement()), model: fieldModel, - editorView: editorView, - toolbarId: toolbarView.getId() + editorView: editorView }); // Track these three views in FieldModel so that we can tear them down @@ -252,6 +275,9 @@ Drupal.edit.AppView = Backbone.View.extend({ * @see acceptEditorStateChange() */ _confirmStopEditing: function () { + var activeEntity = Drupal.edit.collections.entities.where({ isActive: true })[0]; + // Set the active entity to opened while we confirm the field changes. + activeEntity.set('state', 'opened'); // Only instantiate if there isn't a modal instance visible yet. if (!this.model.get('activeModal')) { var that = this; @@ -259,15 +285,31 @@ Drupal.edit.AppView = Backbone.View.extend({ 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') } + { + action: 'save', + type: 'submit', + classes: 'action-save edit-button', + label: Drupal.t('Save') + }, + { + action: 'discard', + classes: 'action-cancel edit-button', + label: Drupal.t('Discard changes') + } ], callback: function (action) { // The active modal has been removed. that.model.set('activeModal', null); - // Set the state that matches the user's action. - var targetState = (action === 'discard') ? 'candidate' : 'saving'; - that.model.get('activeEditor').set('state', targetState, { confirmed: true }); + // If the targetState is saving, the field must be saved, then the + // entity must be saved. + if (action === 'save') { + activeEntity.set('state', 'quitcommitting'); + } + else { + activeEntity.set('state', 'deactivating', { + confirmed: true + }); + } } }); this.model.set('activeModal', modal); @@ -292,7 +334,7 @@ Drupal.edit.AppView = Backbone.View.extend({ if (_.indexOf(this.singleEditorStates, to) !== -1 && this.model.get('highlightedEditor') !== fieldModel) { this.model.set('highlightedEditor', fieldModel); } - else if (this.model.get('highlightedEditor') === fieldModel && to === 'candidate') { + else if (this.model.get('highlightedEditor') === fieldModel && to === 'candidate' || to === 'inactive') { this.model.set('highlightedEditor', null); } @@ -301,7 +343,6 @@ Drupal.edit.AppView = Backbone.View.extend({ this.model.set('activeEditor', fieldModel); } else if (this.model.get('activeEditor') === fieldModel && to === 'candidate') { - // Discarded if it transitions from a changed state to 'candidate'. if (from === 'changed' || from === 'invalid') { fieldModel.editorView.revert(); } @@ -310,6 +351,46 @@ Drupal.edit.AppView = Backbone.View.extend({ }, /** + * + */ + enableEditor: function (fieldModel) { + // check if there's an active editor. + var activeEditor = this.model.get('activeEditor'); + + // Do nothing if the fieldModel is already the active editor. + if (fieldModel === activeEditor) { + return; + } + if (activeEditor) { + // If there is, check if the model is changed. + if (activeEditor.get('state') === 'changed') { + // Save a reference to the changed field so it can be marked as + // as changed until the tempStore is pushed to permanent storage. + this.changedFieldsInTempstore.push(activeEditor.id); + // Attempt to save the field. + activeEditor.set({'state': 'saving'}, { + // This callback will be invoked if the activeEditor field is + // successfully saved. + callback: function () { + // Set the new fieldModel to activating. + fieldModel.set('state', 'activating'); + } + }); + } + // else, set it to a candidate. + else { + activeEditor.set('state', 'candidate'); + // Set the new fieldModel to activating. + fieldModel.set('state', 'activating'); + } + } + else { + // Set the new fieldModel to activating. + fieldModel.set('state', 'activating'); + } + }, + + /** * Render an updated field (a field whose 'html' attribute changed). * * @param Drupal.edit.FieldModel fieldModel @@ -363,6 +444,22 @@ Drupal.edit.AppView = Backbone.View.extend({ this.setupEditor(fieldModel); fieldModel.set('state', 'candidate'); } + + // If the field change was only saved to tempstore, mark the field as + // changed. The changed marker will be cleared when the + // Drupal.edit.app.AppView.prototype.save() method is called. + for (var i = 0, fields = this.changedFieldsInTempstore; i < fields.length; i++) { + var changedFieldModel = fields[i]; + if (changedFieldModel === fieldModel.id) { + var $field = $(fieldModel.get('el')); + if ($field.is('.edit-editable')) { + $field.addClass('edit-changed'); + } + else { + $field.find('.edit-editable').addClass('edit-changed'); + } + } + } }, /** @@ -389,4 +486,4 @@ Drupal.edit.AppView = Backbone.View.extend({ } }); -}(jQuery, _, Backbone, Drupal)); +}(jQuery, _, Backbone, Drupal, drupalSettings)); diff --git a/core/modules/edit/js/views/ContextualLinkView.js b/core/modules/edit/js/views/ContextualLinkView.js index 4b4d075..61199e2 100644 --- a/core/modules/edit/js/views/ContextualLinkView.js +++ b/core/modules/edit/js/views/ContextualLinkView.js @@ -17,7 +17,7 @@ Drupal.edit.ContextualLinkView = Backbone.View.extend({ return { 'click a': function (event) { event.preventDefault(); - this.model.set('isActive', !this.model.get('isActive')); + this.model.set('state', 'launching'); }, 'touchEnd a': touchEndToClick }; @@ -33,36 +33,22 @@ Drupal.edit.ContextualLinkView = Backbone.View.extend({ * - strings: the strings for the "Quick edit" link */ initialize: function (options) { + // Insert the text of the quick edit toggle. + this.$el.find('a').text(this.options.strings.quickEdit); // Initial render. this.render(); - // Re-render whenever this entity's isActive attribute changes. this.model.on('change:isActive', this.render, this); - - // Hide the contextual links whenever an in-place editor is active. - this.options.appModel.on('change:activeEditor', this.toggleContextualLinksVisibility, this); }, /** * {@inheritdoc} */ - render: function () { - var strings = this.options.strings; - var text = !this.model.get('isActive') ? strings.quickEdit : strings.stopQuickEdit; - this.$el.find('a').text(text); - return this; - }, + render: function (entityModel, isActive) { + // Hides the contextual links if an in-place editor is active. + this.$el.parents('.contextual').toggle(!isActive); - /** - * Hides the contextual links if an in-place editor is active. - * - * @param Drupal.edit.AppModel appModel - * The application state model. - * @param null|Drupal.edit.FieldModel activeEditor - * The model of the field that is currently being edited, or, if none, null. - */ - toggleContextualLinksVisibility: function (appModel, activeEditor) { - this.$el.parents('.contextual').toggle(activeEditor === null); + return this; } }); diff --git a/core/modules/edit/js/views/EditorDecorationView.js b/core/modules/edit/js/views/EditorDecorationView.js index b9efdc2..2cf5ffc 100644 --- a/core/modules/edit/js/views/EditorDecorationView.js +++ b/core/modules/edit/js/views/EditorDecorationView.js @@ -7,7 +7,6 @@ "use strict"; Drupal.edit.EditorDecorationView = Backbone.View.extend({ - toolbarId: null, _widthAttributeIsEmpty: null, @@ -25,14 +24,10 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ * @param Object options * An object with the following keys: * - Drupal.edit.EditorView editorView: the editor object view. - * - String toolbarId: the ID attribute of the toolbar as rendered in the - * DOM. */ initialize: function (options) { this.editorView = options.editorView; - this.toolbarId = options.toolbarId; - this.model.on('change:state', this.stateChange, this); }, @@ -61,6 +56,7 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ this.undecorate(); break; case 'candidate': + this.markChanged(false); this.decorate(); if (from !== 'inactive') { this.stopHighlight(); @@ -84,6 +80,7 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ this.startEdit(); break; case 'changed': + this.markChanged(true); break; case 'saving': break; @@ -101,10 +98,8 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ */ onMouseEnter: function (event) { var that = this; - this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { - that.model.set('state', 'highlighted'); - event.stopPropagation(); - }); + that.model.set('state', 'highlighted'); + event.stopPropagation(); }, /** @@ -114,10 +109,8 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ */ onMouseLeave: function (event) { var that = this; - this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { - that.model.set('state', 'candidate', { reason: 'mouseleave' }); - event.stopPropagation(); - }); + that.model.set('state', 'candidate', { reason: 'mouseleave' }); + event.stopPropagation(); }, /** @@ -126,7 +119,7 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ * @param jQuery event */ onClick: function (event) { - this.model.set('state', 'activating'); + Drupal.edit.app.enableEditor(this.model); event.preventDefault(); event.stopPropagation(); }, @@ -135,7 +128,7 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ * Adds classes used to indicate an elements editable state. */ decorate: function () { - this.$el.addClass('edit-animate-fast edit-candidate edit-editable'); + this.$el.addClass('edit-candidate edit-editable'); }, /** @@ -169,9 +162,6 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ */ prepareEdit: function () { this.$el.addClass('edit-editing'); - - // While editing, do not show any other editors. - $('.edit-candidate').not('.edit-editing').removeClass('edit-editable'); }, /** @@ -184,6 +174,17 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ }, /** + * Adds an edit-changed class to a changed field. + * + * @param Boolean toggle + * A boolean that indicates if the edit-toggle class should be added (true) + * or removed (false). + */ + markChanged: function (toggle) { + this.$el.toggleClass('edit-changed', toggle); + }, + + /** * Removes the class that indicates that an element is being edited. * * Reapplies the class that indicates that a candidate editable element is @@ -214,7 +215,6 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ this._widthAttributeIsEmpty = true; this.$el .addClass('edit-animate-disable-width') - .css('width', this.$el.width()) .css('background-color', this._getBgColor(this.$el)); } @@ -234,7 +234,9 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ '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' + 'margin-bottom': posProp['margin-bottom'] - 10 + 'px', + 'width': '100%', + 'box-sizing': 'content-box' }); }, 0); }, @@ -264,13 +266,13 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ 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' + 'margin-bottom': posProp['margin-bottom'] + 10 + 'px', + 'width': 'auto', + 'box-sizing': 'inherit' }); }, 0); }, @@ -332,26 +334,6 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ pos = '0px'; } return pos; - }, - - /** - * Ignores hovering to/from the given closest element. - * - * When a hover occurs to/from another element, invoke the callback. - * - * @param jQuery event - * @param jQuery closest - * A jQuery-wrapped DOM element or compatibale jQuery input. The element - * whose mouseenter and mouseleave events should be ignored. - * @param Function callback - */ - _ignoreHoveringVia: function (event, closest, callback) { - if ($(event.relatedTarget).closest(closest).length > 0) { - event.stopPropagation(); - } - else { - callback(); - } } }); diff --git a/core/modules/edit/js/views/EditorView.js b/core/modules/edit/js/views/EditorView.js index 221765d..0569b1d 100644 --- a/core/modules/edit/js/views/EditorView.js +++ b/core/modules/edit/js/views/EditorView.js @@ -43,6 +43,7 @@ Drupal.edit.EditorView = Backbone.View.extend({ // The el property is the field, which should not be removed. Remove the // pointer to it, then call Backbone.View.prototype.remove(). this.setElement(); + this.fieldModel.off(null, null, this); Backbone.View.prototype.remove.call(this); }, @@ -86,7 +87,7 @@ Drupal.edit.EditorView = Backbone.View.extend({ * @param String state * The state of the associated field. One of Drupal.edit.FieldModel.states. */ - stateChange: function (fieldModel, state) { + stateChange: function (fieldModel, state, options) { var from = fieldModel.previous('state'); var to = state; switch (to) { @@ -102,6 +103,11 @@ Drupal.edit.EditorView = Backbone.View.extend({ if (from === 'invalid') { this.removeValidationErrors(); } + + // Attempt to save if the field was previously in the changed state. + if (from === 'changed') { + fieldModel.set('state', 'saving', options); + } break; case 'highlighted': // Nothing to do for the typical in-place editor: it should not be @@ -138,7 +144,7 @@ Drupal.edit.EditorView = Backbone.View.extend({ if (from === 'invalid') { this.removeValidationErrors(); } - this.save(); + this.save(options); break; case 'saved': // Nothing to do for the typical in-place editor. Immediately after @@ -168,9 +174,10 @@ Drupal.edit.EditorView = Backbone.View.extend({ /** * Saves the modified value in the in-place editor for this field. */ - save: function () { + save: function (options) { var fieldModel = this.fieldModel; var editorModel = this.model; + var callback = (options || {}).callback || function () {}; function fillAndSubmitForm (value) { var $form = $('#edit_backstage form'); @@ -186,7 +193,8 @@ Drupal.edit.EditorView = Backbone.View.extend({ var formOptions = { fieldID: this.fieldModel.id, $el: this.$el, - nocssjs: true + nocssjs: true, + reset: Drupal.edit.app.changedFieldsInTempstore.length === 0 }; Drupal.edit.util.form.load(formOptions, function (form, ajax) { // Create a backstage area for storing forms that are hidden from view @@ -217,6 +225,10 @@ Drupal.edit.EditorView = Backbone.View.extend({ // Then, set the 'html' attribute on the field model. This will cause // the field to be rerendered. fieldModel.set('html', response.data); + + // Invoke the optional callback. The callback will most likely be an + // anonymous function from AppView.enableEditor(). + callback.call(); }; // Unsuccessfully saved; validation errors. @@ -246,7 +258,7 @@ Drupal.edit.EditorView = Backbone.View.extend({ showValidationErrors: function () { var $errors = $('
') .append(this.model.get('validationErrors')); - $(this.fieldModel.get('el')) + this.getEditedElement() .addClass('edit-validation-error') .after($errors); }, @@ -260,12 +272,11 @@ Drupal.edit.EditorView = Backbone.View.extend({ * invalid value was discarded. */ removeValidationErrors: function () { - $(this.fieldModel.get('el')) + this.getEditedElement() .removeClass('edit-validation-error') .next('.edit-validation-errors') .remove(); } - }); }(jQuery, _, Backbone, Drupal)); diff --git a/core/modules/edit/js/views/EntityToolbarView.js b/core/modules/edit/js/views/EntityToolbarView.js new file mode 100644 index 0000000..9429027 --- /dev/null +++ b/core/modules/edit/js/views/EntityToolbarView.js @@ -0,0 +1,315 @@ +/** + * @file + * A Backbone View that provides an entity level toolbar. + */ +(function ($, Backbone, Drupal, debounce) { + +"use strict"; + +Drupal.edit.EntityToolbarView = Backbone.View.extend({ + + _fieldToolbarRoot: null, + + events: function () { + var map = { + 'click.edit button.action-save': 'onClickSave', + 'click.edit button.action-cancel': 'onClickCancel', + 'mouseenter.edit': 'onMouseenter' + }; + return map; + }, + + /** + * {@inheritdoc} + */ + initialize: function (options) { + this.appModel = options.appModel; + + this.model.on('change:isActive change:isDirty change:state', this.render, this); + + this.appModel.on('change:highlightedEditor', this.render, this); + this.appModel.on('change:activeEditor', this.render, this); + + $(window).on('resize.edit scroll.edit', debounce($.proxy(this.windowChangeHandler, this), 150)); + + // Set the el into its own property. Eventually the el property will be + // replaced with the rendered toolbar. + this.$entity = this.$el; + + // Set the toolbar container to this view's el property. + this.buildToolbarEl(); + this._fieldToolbarRoot = this.$el.find('.edit-toolbar-field').get(0); + + this.render(); + }, + + /** + * {@inheritdoc} + */ + render: function (model, changeValue) { + + if (this.model.get('isActive')) { + // If the toolbar container doesn't exist, create it. + if ($('body').children('#edit-entity-toolbar').length === 0) { + $('body').append(this.$el); + } + // Adds the entity title to the toolbar. + this.label(); + + // Show the save and cancel buttons. + this.show('ops'); + // If render is being called and the toolbar is already visible, just + // reposition it. + this.position(); + } + + // The save button text and state varies with the state of the entity model. + var $button = this.$el.find('.edit-button.action-save'); + var isDirty = this.model.get('isDirty'); + // Adjust the save button according to the state of the model. + switch (this.model.get('state')) { + // Quick editing is active, but no field is being edited. + case 'opened': + $button.attr('aria-hidden', true); + break; + // A field is being edited. + case 'editing': + // The saving throbber is not managed by AJAX system. The + // EntityToolbarView manages this visual element. + $button + .removeClass('action-saving icon-throbber icon-end') + .text(Drupal.t('Save')) + .removeAttr('disabled'); + setTimeout(function () { + $button.attr('aria-hidden', !isDirty); + }); + break; + // The changes to the fields of the entity are being committed. + case 'committing': + $button + .addClass('action-saving icon-throbber icon-end') + .text(Drupal.t('Saving')) + .attr('disabled', 'disabled'); + break; + default: + $button.attr('aria-hidden', true); + break; + } + + return this; + }, + + /** + * Repositions the entity toolbar on window scroll and resize. + * + * @param jQuery.Eevent event + */ + windowChangeHandler: function (event) { + this.position(); + }, + + /** + * Uses the jQuery.ui.position() method to position the entity toolbar. + * + * @param jQuery|DOM element + * (optional) The element against which the entity toolbar is positioned. + */ + position: function (element) { + clearTimeout(this.timer); + var that = this; + // Vary the edge of the positioning according to the direction of language + // in the document. + var edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left'; + // If a field in this entity is active, position against it. + var activeEditor = Drupal.edit.app.model.get('activeEditor'); + var activeEditorView = activeEditor && activeEditor.editorView; + var activeEditedElement = activeEditorView && activeEditorView.getEditedElement(); + // Label of a highlighted field, if it exists. + var highlightedEditor = Drupal.edit.app.model.get('highlightedEditor'); + var highlightedEditorView = highlightedEditor && highlightedEditor.editorView; + var highlightedEditedElement = highlightedEditorView && highlightedEditorView.getEditedElement(); + // Prefer the specified element from the parameters, then the active field + // and finally the entity itself to determine the position of the toolbar. + var of = element || activeEditedElement || highlightedEditedElement || this.$entity; + // Uses the jQuery.ui.position() method. Use a timeout to move the toolbar + // only after the user has focused on an editable for 250ms. This prevents + // the toolbar from jumping around the screen. + this.timer = setTimeout(function () { + that.$el + .position({ + my: edge + '-2 bottom-3', + at: edge + ' top', + of: of, + collision: 'flipfit', + // Eliminate some of the placement jitteriness by flooring the suggested + // values. + using: function (suggested, info) { + info.element.element.css({ + left: Math.floor(suggested.left), + top: Math.floor(suggested.top) + }); + } + }) + // Resize the toolbar to match the dimensions of the field, up to a max + // width that is equal to the field's width. + .css({ + 'max-width': $(of).outerWidth(), + 'width': '100%' + }); + }, 250); + }, + + /** + * Set the model state to 'saving' when the save button is clicked. + * + * @param jQuery event + */ + onClickSave: function (event) { + event.stopPropagation(); + event.preventDefault(); + // Save the model. + this.model.set('state', 'committing'); + }, + + /** + * Sets the model state to candidate when the cancel button is clicked. + * + * @param jQuery event + */ + onClickCancel: function (event) { + event.preventDefault(); + this.model.set('state', 'deactivating'); + }, + + /** + * Clears the timeout that will eventually reposition the entity toolbar. + * + * @param jQuery event + */ + onMouseenter: function (event) { + clearTimeout(this.timer); + }, + + /** + * Builds the entity toolbar HTML; Attaches to DOM; Sets it as the view el. + */ + buildToolbarEl: function () { + var $toolbar = $(Drupal.theme('editEntityToolbar', { + id: 'edit-entity-toolbar' + })); + + $toolbar + .find('.edit-toolbar-entity') + // Append the "ops" toolgroup into the toolbar. + .prepend(Drupal.theme('editToolgroup', { + classes: ['ops'], + buttons: [ + { + label: Drupal.t('Save'), + type: 'submit', + classes: 'action-save edit-button icon', + attributes: { + 'aria-hidden': true + } + }, + { + label: Drupal.t('Close'), + classes: 'action-cancel edit-button icon icon-close icon-only' + } + ] + })); + + // Give the toolbar a sensible starting position so that it doesn't + // animiate on to the screen from a far off corner. + $toolbar + .css({ + left: this.$entity.offset().left, + top: this.$entity.offset().top + }); + + // Set the entity toolbar DOM element as the el for this view. + this.setElement($toolbar); + }, + + /** + * Returns the DOM element that fields will attach their toolbars to. + * + * @return jQuery + * The DOM element that fields will attach their toolbars to. + */ + getToolbarRoot: function () { + return this._fieldToolbarRoot; + }, + + /** + * Generates a state-dependent label for the entity toolbar. + */ + label: function () { + // The entity label. + var label = '"' + this.model.get('label') + '"'; + + // Label of an active field, if it exists. + var activeEditor = Drupal.edit.app.model.get('activeEditor'); + var activeFieldLabel = activeEditor && activeEditor.get('metadata').label; + activeFieldLabel = activeFieldLabel && activeFieldLabel + ' — ' + label; + + // Label of a highlighted field, if it exists. + var highlightedEditor = Drupal.edit.app.model.get('highlightedEditor'); + var highlightedFieldLabel = highlightedEditor && highlightedEditor.get('metadata').label; + highlightedFieldLabel = highlightedFieldLabel && highlightedFieldLabel + ' — ' + label; + + this.$el + .find('.edit-toolbar-label') + .text(activeFieldLabel || highlightedFieldLabel || label); + }, + + /** + * Adds classes to a toolgroup. + * + * @param String toolgroup + * A toolgroup name. + * @param String classes + * A string of space-delimited class names that will be applied to the + * wrapping element of the toolbar group. + */ + addClass: function (toolgroup, classes) { + this._find(toolgroup).addClass(classes); + }, + + /** + * Removes classes from a toolgroup. + * + * @param String toolgroup + * A toolgroup name. + * @param String classes + * A string of space-delimited class names that will be removed from the + * wrapping element of the toolbar group. + */ + removeClass: function (toolgroup, classes) { + this._find(toolgroup).removeClass(classes); + }, + + /** + * Finds a toolgroup. + * + * @param String toolgroup + * A toolgroup name. + * @return jQuery + * The toolgroup DOM element. + */ + _find: function (toolgroup) { + return this.$el.find('.edit-toolbar .edit-toolgroup.' + toolgroup); + }, + + /** + * Shows a toolgroup. + * + * @param String toolgroup + * A toolgroup name. + */ + show: function (toolgroup) { + this.$el.removeClass('edit-animate-invisible'); + } +}); + +})(jQuery, Backbone, Drupal, Drupal.debounce); diff --git a/core/modules/edit/js/views/EntityView.js b/core/modules/edit/js/views/EntityView.js new file mode 100644 index 0000000..a2e771a --- /dev/null +++ b/core/modules/edit/js/views/EntityView.js @@ -0,0 +1,31 @@ +(function ($, Backbone) { + +"use strict"; + +Drupal.edit.EntityView = Backbone.View.extend({ + /** + * {@inheritdoc} + * + * Associated with the DOM root node of an editable entity. + */ + initialize: function () { + this.model.on('change', this.render, this); + }, + + /** + * {@inheritdoc} + */ + render: function () { + this.$el.toggleClass('edit-entity-active', this.model.get('isActive')); + }, + + /** + * {@inheritdoc} + */ + remove: function () { + this.setElement(null); + Backbone.View.prototype.remove.call(this); + } +}); + +}(jQuery, Backbone)); diff --git a/core/modules/edit/js/views/FieldToolbarView.js b/core/modules/edit/js/views/FieldToolbarView.js index 4cdd53d..4aefe7b 100644 --- a/core/modules/edit/js/views/FieldToolbarView.js +++ b/core/modules/edit/js/views/FieldToolbarView.js @@ -14,27 +14,15 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ // A reference to the in-place editor. editorView: 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' - }, - /** * {@inheritdoc} */ initialize: function (options) { this.$editedElement = options.$editedElement; this.editorView = options.editorView; - - this._loader = null; - this._loaderVisibleStart = 0; + this.$root = this.$el; // Generate a DOM-compatible ID for the form container DOM element. this._id = 'edit-toolbar-for-' + this.model.id.replace(/\//g, '_'); @@ -46,19 +34,19 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ * {@inheritdoc} */ render: function () { - // Render toolbar. - this.setElement($(Drupal.theme('editToolbarContainer', { + // Render toolbar and set it as the view's element. + this.setElement($(Drupal.theme('editFieldToolbar', { id: this._id }))); // Insert in DOM. if (this.$editedElement.css('display') === 'inline') { - this.$el.prependTo(this.$editedElement.offsetParent()); - var pos = this.$editedElement.position(); - this.$el.css('left', pos.left).css('top', pos.top); + this.$el.prependTo(this.$field); } else { - this.$el.insertBefore(this.$editedElement); + // The $root is the DOM element in the entity toolbar that field toolbars + // attach to. + this.$el.prependTo(this.$root); } return this; @@ -81,240 +69,52 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ } break; case 'candidate': - if (from === 'inactive') { - this.render(); - } - else { - // Remove all toolgroups; they're no longer necessary. - this.$el - .removeClass('edit-highlighted edit-editing') - .find('.edit-toolbar .edit-toolgroup').remove(); - if (from !== 'highlighted' && this.editorView.getEditUISettings().padding) { - this._unpad(); - } - } break; case 'highlighted': - // As soon as we highlight, make sure we have a toolbar in the DOM (with - // at least a title). - this.startHighlight(); break; case 'activating': - this.setLoadingIndicator(true); - break; - case 'active': - this.startEdit(); - this.setLoadingIndicator(false); + this.render(); + if (this.editorView.getEditUISettings().fullWidthToolbar) { this.$el.addClass('edit-toolbar-fullwidth'); } - if (this.editorView.getEditUISettings().padding) { - this._pad(); - } if (this.editorView.getEditUISettings().unifiedToolbar) { this.insertWYSIWYGToolGroups(); } break; + case 'active': + break; case 'changed': - this.$el - .find('button.save') - .addClass('blue-button') - .removeClass('gray-button'); break; case 'saving': - this.setLoadingIndicator(true); break; case 'saved': - this.setLoadingIndicator(false); break; case 'invalid': - this.setLoadingIndicator(false); break; } }, /** - * Redirects the click.edit-event to the editor DOM element. - * - * @param jQuery event - */ - onClickInfoLabel: function (event) { - event.stopPropagation(); - event.preventDefault(); - // Redirects the event to the editor DOM element. - this.$editedElement.trigger('click.edit'); - }, - - /** - * Controls mouseleave events. - * - * A mouseleave to the editor doesn't matter; a mouseleave to something else - * counts as a mouseleave on the editor itself. - * - * @param jQuery event - */ - onMouseLeave: function (event) { - if (event.relatedTarget !== this.$editedElement[0] && !$.contains(this.$editedElement, event.relatedTarget)) { - this.$editedElement.trigger('mouseleave.edit'); - } - event.stopPropagation(); - }, - - /** - * Set the model state to 'saving' when the save button is clicked. - * - * @param jQuery event - */ - onClickSave: function (event) { - event.stopPropagation(); - event.preventDefault(); - this.model.set('state', 'saving'); - }, - - /** - * Sets the model state to candidate when the cancel button is clicked. - * - * @param jQuery event - */ - onClickClose: function (event) { - event.stopPropagation(); - event.preventDefault(); - this.model.set('state', 'candidate', { 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 Boolean 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; - } - }, - - /** - * Decorate the field with markup to indicate it is highlighted. - */ - startHighlight: function () { - // Retrieve the lavel to show for this field. - var label = this.model.get('metadata').label; - - this.$el - .addClass('edit-highlighted') - .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); - }, - - /** - * Decorate the field with markup to indicate edit state; append a toolbar. - */ - 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: '' + Drupal.t('Close') + '', classes: 'field-close close gray-button' } - ] - })); - this.show('ops'); - }, - - /** - * Adjusts the toolbar to accomodate padding on the editor. - * - * @see EditorDecorationView._pad(). - */ - _pad: function () { - // The whole toolbar must move to the top when the property's DOM element - // is displayed inline. - if (this.$editedElement.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' }); - - if (this.editorView.getEditUISettings().fullWidthToolbar) { - $hf.css({ width: this.$editedElement.width() + 10 }); - } - }, - - /** - * Undoes the changes made by _pad(). - * - * @see EditorDecorationView._unpad(). - */ - _unpad: function () { - // Move the toolbar back to its original position. - var $hf = this.$el.find('.edit-toolbar-heightfaker'); - $hf.css({ bottom: '1px', left: '' }); - - if (this.editorView.getEditUISettings().fullWidthToolbar) { - $hf.css({ width: '' }); - } - }, - - /** * Insert WYSIWYG markup into the associated toolbar. */ insertWYSIWYGToolGroups: function () { this.$el - .find('.edit-toolbar') .append(Drupal.theme('editToolgroup', { id: this.getFloatedWysiwygToolgroupId(), - classes: 'wysiwyg-floated', + classes: ['wysiwyg-floated', 'edit-animate-slow', 'edit-animate-invisible', 'edit-animate-delay-veryfast'], buttons: [] })) .append(Drupal.theme('editToolgroup', { id: this.getMainWysiwygToolgroupId(), - classes: 'wysiwyg-main', + classes: ['wysiwyg-main', 'edit-animate-slow', 'edit-animate-invisible', 'edit-animate-delay-veryfast'], buttons: [] })); // Animate the toolgroups into visibility. - var that = this; - setTimeout(function () { - that.show('wysiwyg-floated'); - that.show('wysiwyg-main'); - }, 0); + this.show('wysiwyg-floated'); + this.show('wysiwyg-main'); }, /** @@ -354,49 +154,36 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ }, /** - * 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. - * @param String classes - * A space delimited list of class names to add to the toolgroup. - */ - addClass: function (toolgroup, classes) { - this._find(toolgroup).addClass(classes); - }, - - /** - * Removes classes from a toolgroup. + * Finds a toolgroup. * * @param String toolgroup * A toolgroup name. - * @param String classes - * A space delimited list of class names to remove from the toolgroup. + * @return jQuery */ - removeClass: function (toolgroup, classes) { - this._find(toolgroup).removeClass(classes); + _find: function (toolgroup) { + return this.$el.find('.edit-toolgroup.' + toolgroup); }, /** - * Finds a toolgroup. + * Shows a toolgroup. * * @param String toolgroup * A toolgroup name. - * @return jQuery */ - _find: function (toolgroup) { - return this.$el.find('.edit-toolbar .edit-toolgroup.' + toolgroup); - } + show: function (toolgroup) { + var $group = this._find(toolgroup); + // Attach a transitionEnd event handler to the toolbar group so that update + // events can be triggered after the animations have ended. + $group.on(Drupal.edit.util.constants.transitionEnd, function (event) { + $group.off(Drupal.edit.util.constants.transitionEnd); + }); + // The call to remove the class and start the animation must be started in + // the next animation frame or the event handler attached above won't be + // triggered. + window.setTimeout(function () { + $group.removeClass('edit-animate-invisible'); + }, 0); + } }); })(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/lib/Drupal/edit/EditController.php b/core/modules/edit/lib/Drupal/edit/EditController.php index b859fe8..2a0dafd 100644 --- a/core/modules/edit/lib/Drupal/edit/EditController.php +++ b/core/modules/edit/lib/Drupal/edit/EditController.php @@ -58,6 +58,8 @@ public function metadata(Request $request) { if (!isset($fields)) { throw new NotFoundHttpException(); } + $entities = $request->request->get('entities'); + $metadataGenerator = $this->container->get('edit.metadata.generator'); $metadata = array(); @@ -81,7 +83,13 @@ public function metadata(Request $request) { throw new NotFoundHttpException(); } - $metadata[$field] = $metadataGenerator->generate($entity, $instance, $langcode, $view_mode); + // If the entity information for this field is requested, include it. + $entity_id = $entity->entityType() . '/' . $entity_id; + if (in_array($entity_id, $entities) && !isset($metadata[$entity_id])) { + $metadata[$entity_id] = $metadataGenerator->generateEntity($entity, $langcode); + } + + $metadata[$field] = $metadataGenerator->generateField($entity, $instance, $langcode, $view_mode); } return new JsonResponse($metadata); diff --git a/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php b/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php index cecc676..5492914 100644 --- a/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php +++ b/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php @@ -56,9 +56,18 @@ public function __construct(EditEntityFieldAccessCheckInterface $access_checker, } /** - * Implements \Drupal\edit\MetadataGeneratorInterface::generate(). + * {@inheritdoc} */ - public function generate(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode) { + public function generateEntity(EntityInterface $entity, $langcode) { + return array( + 'label' => $entity->label($langcode), + ); + } + + /** + * {@inheritdoc} + */ + public function generateField(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode) { $field_name = $instance['field_name']; // Early-return if user does not have access. diff --git a/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php b/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php index 16db770..6d60f33 100644 --- a/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php +++ b/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php @@ -11,11 +11,24 @@ use Drupal\field\Plugin\Core\Entity\FieldInstance; /** - * Interface for generating in-place editing metadata for an entity field. + * Interface for generating in-place editing metadata. */ interface MetadataGeneratorInterface { /** + * Generates in-place editing metadata for an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being edited. + * @param string $langcode + * The name of the language for which the field is being edited. + * @return array + * An array containing metadata with the following keys: + * - label: the user-visible label for the entity. + */ + public function generateEntity(EntityInterface $entity, $langcode); + + /** * Generates in-place editing metadata for an entity field. * * @param \Drupal\Core\Entity\EntityInterface $entity @@ -34,6 +47,6 @@ * - aria: the ARIA label. * - custom: (optional) any additional metadata that the editor provides. */ - public function generate(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode); + public function generateField(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode); } diff --git a/core/modules/editor/js/editor.formattedTextEditor.js b/core/modules/editor/js/editor.formattedTextEditor.js index 91e3521..0b9aa7c 100644 --- a/core/modules/editor/js/editor.formattedTextEditor.js +++ b/core/modules/editor/js/editor.formattedTextEditor.js @@ -55,7 +55,7 @@ Drupal.edit.editors.editor = Drupal.edit.EditorView.extend({ /** * {@inheritdoc} */ - stateChange: function (fieldModel, state) { + stateChange: function (fieldModel, state, options) { var editorModel = this.model; var from = fieldModel.previous('state'); var to = state; @@ -122,7 +122,7 @@ Drupal.edit.editors.editor = Drupal.edit.EditorView.extend({ if (from === 'invalid') { this.removeValidationErrors(); } - this.save(); + this.save(options); break; case 'saved': -- 1.7.10.4