From 0e2ab2f2106f6fdcdf6467274f5607db5dfa2768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?"J.=20Rene=CC=81e=20Beach"?= Date: Fri, 14 Jun 2013 22:20:30 -0400 Subject: [PATCH] Issue #1678002-150 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 621393c1804d8f400d23c1c9d346eca4c6c92b24 Author: J. Renée Beach Date: Fri Jun 14 22:16:23 2013 -0400 Missing an exception case for going from deactivating to committing. Signed-off-by: J. Renée Beach commit ec2fc4e7c914c81dccf6dccb3ed4be4b8212f546 Author: J. Renée Beach Date: Fri Jun 14 22:04:46 2013 -0400 set the width right. Signed-off-by: J. Renée Beach commit db80b27f80fb974aba8abbbd0d04a5b5891d7d29 Author: J. Renée Beach Date: Fri Jun 14 22:02:58 2013 -0400 Using the core button styling Signed-off-by: J. Renée Beach commit 52809c321ea9e3bb326b0d745e6bcd0fa1df9f25 Author: J. Renée Beach Date: Fri Jun 14 21:20:36 2013 -0400 forgot to add an event param to the callback for the offset change event. Signed-off-by: J. Renée Beach commit e0872d89eb0e471b5e17ee7e9e17e933e570c99c Author: J. Renée Beach Date: Fri Jun 14 21:15:11 2013 -0400 small tweeks. Signed-off-by: J. Renée Beach commit efaa240666b08aa141888df24b70923bf82eb9a1 Author: J. Renée Beach Date: Fri Jun 14 20:53:04 2013 -0400 Placement is getting better in terms of displacing element collision avoidance. Signed-off-by: J. Renée Beach commit 9697b3b7bf0ac51190bb26bc27db22a71f837735 Author: J. Renée Beach Date: Fri Jun 14 18:42:36 2013 -0400 Further adjusting the positioning of the toolbar. Signed-off-by: J. Renée Beach commit 1f1ca43cb3f08c1f6107964b027f88387be10389 Author: J. Renée Beach Date: Fri Jun 14 18:23:17 2013 -0400 Further refining placement. Signed-off-by: J. Renée Beach commit acaf8818e9fb5ff7b15a92901999db407d1badf4 Author: J. Renée Beach Date: Fri Jun 14 18:07:37 2013 -0400 Position against the active form Signed-off-by: J. Renée Beach commit f27e7d70c03a344e97b13b538c52e1cf489590b5 Author: J. Renée Beach Date: Fri Jun 14 22:18:25 2013 -0400 1678002-149 Signed-off-by: J. Renée Beach 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 | 342 ++------------ core/modules/edit/css/edit.theme-rtl.css | 3 + core/modules/edit/css/edit.theme.css | 232 ++++++++++ core/modules/edit/edit.module | 7 + 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 | 47 +- core/modules/edit/js/editors/directEditor.js | 4 +- core/modules/edit/js/editors/formEditor.js | 35 +- core/modules/edit/js/models/EntityModel.js | 466 +++++++++++++++++++- core/modules/edit/js/models/FieldModel.js | 75 +++- core/modules/edit/js/theme.js | 58 ++- core/modules/edit/js/util.js | 35 +- core/modules/edit/js/views/AppView.js | 211 ++++++--- core/modules/edit/js/views/ContextualLinkView.js | 28 +- core/modules/edit/js/views/EditorDecorationView.js | 57 +-- core/modules/edit/js/views/EditorView.js | 23 +- core/modules/edit/js/views/EntityToolbarView.js | 386 ++++++++++++++++ core/modules/edit/js/views/EntityView.js | 31 ++ core/modules/edit/js/views/FieldToolbarView.js | 292 ++---------- .../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 | 7 +- 29 files changed, 1700 insertions(+), 750 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..370508d 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,17 @@ 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; +.edit-toolbar-label { + overflow: hidden; } - -/* 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-fence { + bottom: 0; + left: 0; + right: 0; + top: 0; + position: fixed; + z-index: -1; } 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..68010ad --- /dev/null +++ b/core/modules/edit/css/edit.theme.css @@ -0,0 +1,232 @@ +/** + * @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: #e4e4e4; + border: 1px solid #d2d2d2; + color: #5a5a5a; + 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: #50a0e9; + background-image: -moz-linear-gradient(-90deg, #50a0e9, #4481dc); + background-image: -o-linear-gradient(-90deg, #50a0e9, #4481dc); + background-image: -webkit-linear-gradient(-90deg, #50a0e9, #4481dc); + background-image: linear-gradient(180deg, #50a0e9, #4481dc); + border: 1px solid #3974ae; +} +.edit-button:hover, +.edit-button:active { + background-color: #c8c8c8; + border: 1px solid #a0a0a0; + color: #2e2e2e; +} +.edit-button.action-save:hover, +.edit-button.action-save:active { + border: 1px solid #27528c; + color: white; +} +.edit-button.action-saving, +.edit-button.action-saving:hover, +.edit-button.action-saving:active { + background-color: #e4e4e4; + background-image: none; + border-color: #d2d2d2; + color: #5a5a5a; +} diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module index 90314ee..a7fbb5d 100644 --- a/core/modules/edit/edit.module +++ b/core/modules/edit/edit.module @@ -89,6 +89,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, @@ -99,14 +101,19 @@ 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.displace'), 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 a3c1fd7..87becb4 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); @@ -31,6 +30,7 @@ var options = $.extend({ * Tracks fields without metadata. Contains objects with the following keys: * - DOM el * - String fieldID + * - String entityID */ var fieldsMetadataQueue = []; @@ -57,6 +57,10 @@ 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 rerendered after editing, it will be processed + // immediately. New fields will be unable to be processed immediately, but + // will instead be 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 +171,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 +189,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 +240,18 @@ function fetchMissingMetadata (callback) { if (fieldsMetadataQueue.length) { var fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID'); var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el'); + var entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true); + // Ensure we only request entityIDs for which we don't have metadata yet. + entityIDs = _.difference(entityIDs, _.keys(Drupal.edit.metadata.data)); 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. @@ -281,7 +295,7 @@ function loadMissingEditors (callback) { // Create a temporary element to be able to use Drupal.ajax. var $el = $('
').appendTo('body'); // Create a Drupal.ajax instance to load the form. - Drupal.ajax[id] = new Drupal.ajax(id, $el, { + var loadEditorsAjax = new Drupal.ajax(id, $el, { url: Drupal.url('edit/attachments'), event: 'edit-internal.edit', submit: { 'editors[]': missingEditors }, @@ -291,8 +305,8 @@ function loadMissingEditors (callback) { // Implement a scoped insert AJAX command: calls the callback after all AJAX // command functions have been executed (hence the deferred calling). var realInsert = Drupal.AjaxCommands.prototype.insert; - Drupal.ajax[id].commands.insert = function (ajax, response, status) { - _.defer(function() { callback(); }); + loadEditorsAjax.commands.insert = function (ajax, response, status) { + _.defer(callback); realInsert(ajax, response, status); }; // Trigger the AJAX request, which will should return AJAX commands to insert @@ -344,14 +358,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 an EntityView 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,7 +429,9 @@ function deleteContainedModelsAndQueues($context) { var contextualLinkView = entityModels[0].get('contextualLinkView'); contextualLinkView.undelegateEvents(); contextualLinkView.remove(); - + // Remove the EntityView. + entityModels[0].get('entityView').remove(); + // Destroy the EntityModel; this will also destroy its FieldModels. 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 d50b48b..f46fd4f 100644 --- a/core/modules/edit/js/editors/formEditor.js +++ b/core/modules/edit/js/editors/formEditor.js @@ -11,6 +11,9 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ // Tracks the form container DOM element that is used while in-place editing. $formContainer: null, + // Holds the Drupal.ajax object + formSaveAjax: null, + /** * {@inheritdoc} */ @@ -82,8 +85,16 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ var formOptions = { fieldID: fieldModel.id, $el: this.$el, - nocssjs: false + nocssjs: false, + // Reset an existing entry for this entity in the TempStore (if any) when + // saving the field. Logically speaking, this should happen in a separate + // request because this is an entity-level operation, not a field-level + // operation. But that would require an additional request, that might not + // even be necessary: it is only when a user saves a first changed field + // for an entity that this needs to happen: precisely now! + reset: !fieldModel.get('entity').get('inTempStore') }; + var self = this; Drupal.edit.util.form.load(formOptions, function (form, ajax) { Drupal.AjaxCommands.prototype.insert(ajax, { data: form, @@ -91,7 +102,7 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ }); var $submit = $formContainer.find('.edit-form-submit'); - Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); + self.formSaveAjax = Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); $formContainer .on('formUpdated.edit', ':input', function () { fieldModel.set('state', 'changed'); @@ -115,7 +126,7 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ return; } - Drupal.edit.util.form.unajaxifySaving(this.$formContainer.find('.edit-form-submit')); + delete this.formSaveAjax; // Allow form widgets to detach properly. Drupal.detachBehaviors(this.$formContainer, null, 'unload'); this.$formContainer @@ -131,23 +142,23 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ save: function () { 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 self = this; // Successfully saved. - Drupal.ajax[base].commands.editFieldFormSaved = function (ajax, response, status) { - Drupal.edit.util.form.unajaxifySaving($(ajax.element)); + this.formSaveAjax.commands.editFieldFormSaved = function (ajax, response, status) { + delete self.formSaveAjax; // First, transition the state to 'saved'. fieldModel.set('state', 'saved'); // Then, set the 'html' attribute on the field model. This will cause the // field to be rerendered. fieldModel.set('html', response.data); - }; + }; // Unsuccessfully saved; validation errors. - Drupal.ajax[base].commands.editFieldFormValidationErrors = function (ajax, response, status) { + this.formSaveAjax.commands.editFieldFormValidationErrors = function (ajax, response, status) { editorModel.set('validationErrors', response.data); fieldModel.set('state', 'invalid'); }; @@ -157,8 +168,8 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ // 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($(ajax.element)); + this.formSaveAjax.commands.editFieldForm = function (ajax, response, status) { + delete self.formSaveAjax; Drupal.AjaxCommands.prototype.insert(ajax, { data: response.data, @@ -167,7 +178,9 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ // Create a Drupal.ajax instance for the re-rendered ("new") form. var $newSubmit = $formContainer.find('.edit-form-submit'); - Drupal.edit.util.form.ajaxifySaving({ nocssjs: false }, $newSubmit); + self.formSaveAjax = Drupal.edit.util.form.ajaxifySaving({ + nocssjs: false + }, $newSubmit); }; // Click the form's submit button; the scoped AJAX commands above will diff --git a/core/modules/edit/js/models/EntityModel.js b/core/modules/edit/js/models/EntityModel.js index e484b35..51c2ff1 100644 --- a/core/modules/edit/js/models/EntityModel.js +++ b/core/modules/edit/js/models/EntityModel.js @@ -1,4 +1,4 @@ -(function (Backbone, Drupal) { +(function (_, $, Backbone, Drupal) { "use strict"; @@ -14,6 +14,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 +24,24 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ // Indicates whether this instance of this entity is currently being // edited in-place. - isActive: false + isActive: false, + // Whether one or more fields have already been stored in TempStore. + inTempStore: false, + // Whether one or more fields have already been stored in TempStore *or* + // the field that's currently being edited is in the 'changed' or a later + // state. In other words, this boolean indicates whether a "Save" button is + // necessary or not. + isDirty: false, + // Whether the request to the server has been made to commit this entity. + // Used to prevent multiple such requests. + isCommitting: false, + // The current processing state of an entity. + state: 'closed', + // The IDs of the fields whose new values have been stored in TempStore. We + // must store this on the EntityModel as well (even though it already is on + // the FieldModel) because when a field is rerendered, its FieldModel is + // destroyed and this allows us to transition it back to the proper state. + fieldsInTempStore: [] }, /** @@ -30,6 +49,376 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ */ initialize: function () { this.set('fields', new Drupal.edit.FieldCollection()); + + // Respond to entity 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); + }, + + /** + * Updates FieldModels' states when an EntityModel change occurs. + * + * @param Drupal.edit.EntityModel entityModel + * @param String state + * The state of the associated entity. One of Drupal.edit.EntityModel.states. + * @param Object options + */ + stateChange: function (entityModel, state, options) { + var to = state; + switch (to) { + case 'closed': + this.set('isActive', false); + this.set('inTempStore', false); + this.set('isDirty', false); + break; + + case 'launching': + break; + + case 'opening': + // Set the fields to candidate state. + entityModel.get('fields').each(function (fieldModel) { + fieldModel.set('state', 'candidate', options); + }); + break; + + case 'opened': + // The entity is now ready for editing! + this.set('isActive', true); + break; + + case 'committing': + // The user indicated he wants to save the entity, but to do that, all + // fields for the entity must first be stored in TempStore. + this.get('fields').chain() + .filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], Drupal.edit.app.changedEditorStates).length; + }) + .each(function (fieldModel) { + fieldModel.set('state', 'saving'); + }); + break; + + case 'deactivating': + // Set all fields to the '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; + + case 'closing': + // Set all fields to the 'inactive' state. + options.reason = 'stop'; + this.get('fields').each(function (fieldModel) { + fieldModel.set({ + 'inTempStore': false, + 'state': 'inactive' + }, 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 of the associated field. One of Drupal.edit.FieldModel.states. + */ + fieldStateChange: function (fieldModel, state) { + var entityModel = this; + var fieldState = state; + var fieldsInTempStore = this.get('fieldsInTempStore'); + // 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')) { + case 'closed': + case 'launching': + // It should be impossible to reach these: fields can't change state + // while the entity is closed or still launching. + break; + + case 'opening': + // We must change the entity to the 'opened' state, but it must first be + // confirmed that all of its fieldModels have transitioned to the + // 'candidate' state. + // We do this here, because this is called every time a fieldModel + // changes state, hence each time this is called, we get closer to the + // goal of having all fieldModels in the 'candidate' state. + // A state change in reaction to another state change must be deferred. + _.defer(function() { + entityModel.set('state', 'opened', { + 'accept-field-states': Drupal.edit.app.fieldReadyStates + }); + }); + break; + + case 'opened': + // Set the isDirty attribute when appropriate so that it is known when + // to display the "Save" button in the entity toolbar. + // Note that once a field has been changed, there's no way to discard + // that change, hence it will have to be saved into TempStore, or the + // in-place editing of this field will have to be stopped completely. + // In other words: once any field enters the 'changed' field, then for + // the remainder of the in-place editing session, the entity is by + // definition dirty. + if (fieldState === 'changed') { + entityModel.set('isDirty', true); + } + // If the fieldModel changed to the 'saved' state: remember that this + // field was saved to TempStore. + else if (fieldState === 'saved') { + // Mark the entity as saved in TempStore, so that we can pass the + // proper "reset TempStore" boolean value when communicating with the + // server. + entityModel.set('inTempStore', true); + // Mark the field as saved in TempStore, so that visual indicators + // signifying just that may be rendered. + fieldModel.set('inTempStore', true); + // Remember that this field is in TempStore, restore when rerendered. + fieldsInTempStore.push(fieldModel.id); + fieldsInTempStore = _.uniq(fieldsInTempStore); + entityModel.set('fieldsInTempStore', fieldsInTempStore); + } + // If the fieldModel changed to the 'candidate' state from the + // 'inactive' state, then this is a field for this entity that got + // rerendered. Restore its previous 'inTempStore' attribute value. + // @todo consider moving this to AppView.rerenderedFieldToCandidate() when all this logic is moved out of EntityModel. + else if (fieldState === 'candidate' && fieldModel.previous('state') === 'inactive') { + fieldModel.set('inTempStore', _.intersection([fieldModel.id], fieldsInTempStore).length > 0); + } + break; + + case 'committing': + // If the field save returned a validation error, set the state of the + // entity back to opened. + if (fieldState === 'invalid') { + // A state change in reaction to another state change must be deferred. + _.defer(function() { + entityModel.set('state', 'opened', { reason: 'invalid' }); + }); + } + // If the fieldModel changed to the 'candidate' state from the + // 'inactive' state, then this is a field for this entity that got + // rerendered. Restore its previous 'inTempStore' attribute value. + // @todo consider moving this to AppView.rerenderedFieldToCandidate() when all this logic is moved out of EntityModel. + else if (fieldState === 'candidate' && fieldModel.previous('state') === 'inactive') { + fieldModel.set('inTempStore', _.intersection([fieldModel.id], fieldsInTempStore).length > 0); + } + + // Attempt to save the entity. If the entity's fields are not yet all in + // a ready state, the save will not be processed. + var options = { + 'accept-field-states': Drupal.edit.app.fieldReadyStates + }; + if (entityModel.set('isCommitting', true, options)) { + entityModel.save({ + success: function () { + entityModel.set({ + 'state': 'deactivating', + 'isCommitting' : false + }); + } + }); + } + break; + + case 'deactivating': + // When setting the entity to 'closing', require that all fieldModels + // are in either the 'candidate' or 'highlighted' state. + // A state change in reaction to another state change must be deferred. + _.defer(function() { + entityModel.set('state', 'closing', { + 'accept-field-states': Drupal.edit.app.fieldReadyStates + }); + }); + break; + + case 'closing': + // When setting the entity to 'closed', require that all fieldModels are + // in the 'inactive' state. + // A state change in reaction to another state change must be deferred. + _.defer(function() { + entityModel.set('state', 'closed', { + 'accept-field-states': ['inactive'] + }); + }); + break; + } + }, + + /** + * Fires an AJAX request to the REST save URL for an entity. + * + * @param options + * An object of options that contains: + * - success: (optional) A function to invoke if the entity is success- + * fully saved. + */ + save: function (options) { + var entityModel = this; + + // @todo Simplify this once https://drupal.org/node/1533366 lands. + 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 save the entity. + var entitySaverAjax = new Drupal.ajax(id, $el, { + url: Drupal.url('edit/entity/' + entityModel.id), + event: 'edit-save.edit', + progress: { type: 'none' } + }); + // Entity saved successfully. + entitySaverAjax.commands.editEntitySaved = function(ajax, response, status) { + // All fields have been moved from TempStore to permanent storage, update + // the "inTempStore" attribute on FieldModels, on the EntityModel and + // clear EntityModel's "fieldInTempStore" attribute. + entityModel.get('fields').each(function (fieldModel) { + fieldModel.set('inTempStore', false); + }); + entityModel.set('inTempStore', false); + entityModel.set('fieldsInTempStore', []); + + // Clean up. + $(ajax.element).unbind('edit-save.edit'); + // Invoke the optional success callback. + if (options.success) { + options.success.call(entityModel); + } + }; + // Trigger the AJAX request, which will will return the editEntitySaved AJAX + // command to which we then react. + $el.trigger('edit-save.edit'); + }, + + /** + * {@inheritdoc} + * + * @param Object attrs + * The attributes changes in the save or set call. + * @param Object options + * An object with the following option: + * - String reason (optional): a string that conveys a particular reason + * to allow for an exceptional state change. + * - Array accept-field-states (optional) An array of strings that + * represent field states that the entities must be in to validate. For + * example, if accept-field-states is ['candidate', 'highlighted'], then + * all the fields of the entity must be in either of these two states + * for the save or set call to validate and proceed. + */ + validate: function (attrs, options) { + var acceptedFieldStates = options['accept-field-states'] || []; + + // Validate state change. + var currentState = this.get('state'); + var nextState = attrs.state; + if (currentState !== nextState) { + // Ensure it's a valid state. + if (_.indexOf(this.constructor.states, nextState) === -1) { + return '"' + nextState + '" is an invalid state'; + } + + // Ensure it's a state change that is allowed. + // Check if the acceptStateChange function accepts it. + if (!this._acceptStateChange(currentState, nextState, options)) { + return 'state change not accepted'; + } + // If that function accepts it, then ensure all fields are also in an + // acceptable state. + else if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) { + return 'state change not accepted because fields are not in acceptable state'; + } + } + + // Validate setting isCommitting = true. + var currentIsCommitting = this.get('isCommitting'); + var nextIsCommitting = attrs.isCommitting; + if (currentIsCommitting === false && nextIsCommitting === true) { + if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) { + return 'isCommitting change not accepted because fields are not in acceptable state'; + } + } + else if (currentIsCommitting === true && nextIsCommitting === true) { + return "isCommiting is a mutex, hence only changes are allowed"; + } + }, + + // Like @see AppView.acceptEditorStateChange() + // @todo move this into AppView? + _acceptStateChange: function (from, to, context) { + var accept = true; + + // In general, enforce the states sequence. Disallow going back from a + // "later" state to an "earlier" state, except in explicitly allowed + // cases. + if (!this.constructor.followsStateSequence(from, to)) { + accept = false; + + // Allow: closing -> closed. + // Necessary to stop editing an entity. + if (from === 'closing' && to === 'closed') { + accept = true; + } + // Allow: committing -> opened. + // Necessary to be able to correct an invalid field. + else if (from === 'committing' && to === 'opened' && context.reason && context.reason === 'invalid') { + accept = true; + } + // Allow: opened -> deactivating. + // Necessary to be able to stop editing. + else if (from === 'opened' && to === 'deactivating' && context.confirmed) { + accept = true; + } + // Allow: deactivating -> committing + // Necessary to be able to commit from a confirmation dialog. + else if (from === 'deactivating' && to === 'committing' && context.confirmed) { + accept = true; + } + } + + return accept; + }, + + /** + * @param Array acceptedFieldStates + * @see validate() + * @return Boolean + */ + _fieldsHaveAcceptableStates: function (acceptedFieldStates) { + var accept = true; + + // If no acceptable field states are provided, assume all field states are + // acceptable. We want to let validation pass as a default and only + // check validity on calls to set that explicitly request it. + if (acceptedFieldStates.length > 0) { + var fieldStates = this.get('fields').pluck('state') || []; + // If not all fields are in one of the accepted field states, then we + // still can't allow this state change. + if (_.difference(fieldStates, acceptedFieldStates).length) { + accept = false; + } + } + + return accept; }, /** @@ -52,10 +441,81 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ return; } +}, { + + /** + * A list (sequence) of all possible states an entity can be in during + * in-place editing. + */ + states: [ + // Initial state, like field's 'inactive' OR the user has just finished + // in-place editing this entity. + // - Trigger: none (initial) or EntityModel (finished). + // - Expected behavior: (when not initial state): tear down + // EntityToolbarView, in-place editors and related views. + 'closed', + // User has activated in-place editing of this entity. + // - Trigger: user. + // - Expected behavior: the EntityToolbarView is gets set up, in-place + // editors (EditorViews) and related views for this entity's fields are + // set up. Upon completion of those, the state is changed to 'opening'. + 'launching', + // Launching has finished. + // - Trigger: application. + // - Guarantees: in-place editors ready for use, all entity and field views + // have been set up, all fields are in the 'inactive' state. + // - Expected behavior: all fields are changed to the 'candidate' state and + // once this is completed, the entity state will be changed to 'opened'. + 'opening', + // Opening has finished. + // - Trigger: EntityModel. + // - Guarantees: see 'opening', all fields are in the 'candidate' state. + // - Expected behavior: the user is able to actually use in-place editing. + 'opened', + // User has clicked the 'Save' button (and has thus changed at least one + // field). + // - Trigger: user. + // - Guarantees: see 'opened', plus: either a changed field is in TempStore, + // or the user has just modified a field without activating (switching to) + // another field. + // - Expected behavior: 1) if any of the fields are not yet in TempStore, + // save them to TempStore, 2) if then any of the fields has the 'invalid' + // state, then change the entity state back to 'opened', otherwise: save + // the entity by committing it from TempStore into permanent storage. + 'committing', + // User has clicked the 'Close' button, or has clicked the 'Save' button and + // that was successfully completed. + // - Trigger: user or EntityModel. + // - Guarantees: when having clicked 'Close' hardly any: fields may be in a + // variety of states; when having clicked 'Save': all fields are in the + // 'candidate' state. + // - Expected behavior: transition all fields to the 'candidate' state, + // possibly requiring confirmation in the case of having clicked 'Close'. + 'deactivating', + // Deactivation has been completed. + // - Trigger: EntityModel. + // - Guarantees: all fields are in the 'candidate' state. + // - Expected behavior: change all fields to the 'inactive' state. + 'closing' + ], + + /** + * Indicates whether the 'from' state comes before the 'to' state. + * + * @param String from + * One of Drupal.edit.EntityModel.states. + * @param String to + * One of Drupal.edit.EntityModel.states. + * @return Boolean + */ + followsStateSequence: function (from, to) { + return _.indexOf(this.states, from) < _.indexOf(this.states, to); + } + }); Drupal.edit.EntityCollection = Backbone.Collection.extend({ model: Drupal.edit.EntityModel }); -}(Backbone, Drupal)); +}(_, jQuery, Backbone, Drupal)); diff --git a/core/modules/edit/js/models/FieldModel.js b/core/modules/edit/js/models/FieldModel.js index dc02eab..8b06521 100644 --- a/core/modules/edit/js/models/FieldModel.js +++ b/core/modules/edit/js/models/FieldModel.js @@ -31,6 +31,12 @@ Drupal.edit.FieldModel = Backbone.Model.extend({ // In-place editing state of this field. Defaults to the initial state. // Possible values: @see Drupal.edit.FieldModel.states. state: 'inactive', + // The field is currently in the 'changed' state or one of the following + // states in which the field is still changed. + isChanged: false, + // Is tracked by the EntityModel, is mirrored here solely for decorative + // purposes: so that EditorDecorationView.renderChanged() can react to it. + inTempStore: false, // The full HTML representation of this field (with the element that has // the data-edit-id as the outer element). Used to propagate changes from // this field instance to other instances of the same field. @@ -83,7 +89,7 @@ Drupal.edit.FieldModel = Backbone.Model.extend({ return '"' + next + '" is an invalid state'; } // Check if the acceptStateChange callback accepts it. - if (!this.get('acceptStateChange')(current, next, options)) { + if (!this.get('acceptStateChange')(current, next, options, this)) { return 'state change not accepted'; } } @@ -106,8 +112,71 @@ Drupal.edit.FieldModel = Backbone.Model.extend({ * editing. */ states: [ - 'inactive', 'candidate', 'highlighted', - 'activating', 'active', 'changed', 'saving', 'saved', 'invalid' + // The field associated with this FieldModel is linked to an EntityModel; + // the user can choose to start in-place editing that entity (and + // consequently this field). No in-place editor (EditorView) is associated + // with this field, because this field is not being in-place edited. + // This is both the initial (not yet in-place editing) and the end state ( + // finished in-place editing). + 'inactive', + // The user is in-place editing this entity, and this field is a candidate + // for in-place editing. In-place editor should not + // - Trigger: user. + // - Guarantees: entity is ready, in-place editor (EditorView) is associated + // with the field. + // - Expected behavior: visual indicators around the field indicate it is + // available for in-place editing, no in-place editor presented yet. + 'candidate', + // User is highlighting this field. + // - Trigger: user. + // - Guarantees: see 'candidate'. + // - Expected behavior: visual indicators to convey highlighting, in-place + // editing toolbar shows field's label. + 'highlighted', + // User has activated the in-place editing of this field; in-place editor is + // activating. + // - Trigger: user. + // - Guarantees: see 'candidate'. + // - Expected behavior: loading indicator, in-place editor is loading remote + // data (e.g. retrieve form from back-end). Upon retrieval of remote data, + // the in-place editor transitions the field's state to 'active'. + 'activating', + // In-place editor has finished loading remote data; ready for use. + // - Trigger: in-place editor. + // - Guarantees: see 'candidate'. + // - Expected behavior: in-place editor for the field is ready for use. + 'active', + // User has modified values in the in-place editor. + // - Trigger: user. + // - Guarantees: see 'candidate', plus in-place editor is ready for use. + // - Expected behavior: visual indicator of change. + 'changed', + // User is saving changed field data in in-place editor to TempStore. The + // save mechanism of the in-place editor is called. + // - Trigger: user. + // - Guarantees: see 'candidate' and 'active'. + // - Expected behavior: saving indicator, in-place editor is saving field + // data into TempStore. Upon succesful saving (without validation errors), + // the in-place editor transitions the field's state to 'saved', but to + // 'invalid' upon failed saving (with validation errors). + 'saving', + // In-place editor has successfully saved the changed field. + // - Trigger: in-place editor. + // - Guarantees: see 'candidate' and 'active'. + // - Expected behavior: transition back to 'candidate' state because the + // deed is done. Then: 1) transition to 'inactive' to allow the field to + // be rerendered, 2) destroy the FieldModel (which also destroys attached + // views like the EditorView), 3) replace the existing field HTML with the + // existing HTML and 4) attach behaviors again so that the field becomes + // available again for in-place editing. + 'saved', + // In-place editor has failed to saved the changed field: there were + // validation errors. + // - Trigger: in-place editor. + // - Guarantees: see 'candidate' and 'active'. + // - Expected behavior: remain in 'invalid' state, let the user make more + // changes so that he can save it again, without validation errors. + 'invalid' ], /** diff --git a/core/modules/edit/js/theme.js b/core/modules/edit/js/theme.js index 3e6e250..8317aa6 100644 --- a/core/modules/edit/js/theme.js +++ b/core/modules/edit/js/theme.js @@ -40,23 +40,47 @@ 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; }; /** + * Element that defines a containing box of the placement of the entity toolbar. + * + * @return String + * The corresponding HTML. + */ +Drupal.theme.editEntityToolbarFence = function () { + return '
'; +}; + +/** + * 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 +92,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/util.js b/core/modules/edit/js/util.js index ad7136ec..6ab6b27 100644 --- a/core/modules/edit/js/util.js +++ b/core/modules/edit/js/util.js @@ -46,6 +46,8 @@ Drupal.edit.util.form = { * field for which this form will be loaded. * - Boolean nocssjs: (required) boolean indicating whether no CSS and JS * should be returned (necessary when the form is invisible to the user). + * - Boolean reset: (required) boolean indicating whether the data stored + * for this field's entity in TempStore should be used or reset. * @param Function 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 @@ -56,18 +58,18 @@ Drupal.edit.util.form = { var fieldID = options.fieldID; // Create a Drupal.ajax instance to load the form. - Drupal.ajax[fieldID] = new Drupal.ajax(fieldID, $el, { + var formLoaderAjax = new Drupal.ajax(fieldID, $el, { url: Drupal.edit.util.buildUrl(fieldID, drupalSettings.edit.fieldFormURL), event: 'edit-internal.edit', - submit: { nocssjs : options.nocssjs }, + submit: { + nocssjs : options.nocssjs, + reset : options.reset + }, progress: { type : null } // No progress indicator. }); // Implement a scoped editFieldForm AJAX command: calls the callback. - Drupal.ajax[fieldID].commands.editFieldForm = function (ajax, response, status) { + formLoaderAjax.commands.editFieldForm = function (ajax, response, status) { callback(response.data, ajax); - // Delete the Drupal.ajax instance that called this very function. - delete Drupal.ajax[fieldID]; - $el.off('edit-internal.edit'); }; // This will ensure our scoped editFieldForm AJAX command gets called. $el.trigger('edit-internal.edit'); @@ -80,8 +82,8 @@ Drupal.edit.util.form = { * 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 String - * The key of the Drupal.ajax instance. + * @return Drupal.ajax + * A Drupal.ajax instance. */ ajaxifySaving: function (options, $submit) { // Re-wire the form to handle submit. @@ -94,10 +96,10 @@ Drupal.edit.util.form = { }; var base = $submit.attr('id'); - Drupal.ajax[base] = new Drupal.ajax(base, $submit[0], element_settings); + var formSaveAjax = new Drupal.ajax(base, $submit[0], element_settings); // Reimplement the success handler to ensure Drupal.attachBehaviors() does // not get called on the form. - Drupal.ajax[base].success = function (response, status) { + formSaveAjax.success = function (response, status) { for (var i in response) { if (response.hasOwnProperty(i) && response[i].command && this.commands[response[i].command]) { this.commands[response[i].command](this, response[i], status); @@ -105,18 +107,7 @@ Drupal.edit.util.form = { } }; - return base; - }, - - /** - * Cleans up the Drupal.ajax instance that is used to save the form. - * - * @param jQuery $submit - * The jQuery-wrapped submit DOM element that should be unajaxified. - */ - unajaxifySaving: function ($submit) { - delete Drupal.ajax[$submit.attr('id')]; - $submit.off('click.edit'); + return formSaveAjax; } }; diff --git a/core/modules/edit/js/views/AppView.js b/core/modules/edit/js/views/AppView.js index 6302356..808be2d 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"; @@ -7,10 +7,6 @@ */ Drupal.edit.AppView = Backbone.View.extend({ - // Configuration for state handling. - activeEditorStates: [], - singleEditorStates: [], - /** * {@inheritdoc} * @@ -25,10 +21,12 @@ Drupal.edit.AppView = Backbone.View.extend({ // @see Drupal.edit.FieldModel.states this.activeEditorStates = ['activating', 'active']; this.singleEditorStates = ['highlighted', 'activating', 'active']; + this.changedEditorStates = ['changed', 'saving', 'saved', 'invalid']; + this.fieldReadyStates = ['candidate', 'highlighted']; 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 @@ -47,30 +45,45 @@ Drupal.edit.AppView = Backbone.View.extend({ * * @param Drupal.edit.EntityModel entityModel * An instance of the EntityModel class. - * @param Boolean isActive - * A boolean that represents the changed active state of the entityModel. + * @param String state + * The state of the associated field. One of Drupal.edit.EntityModel.states. */ - 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 entityToolbarView; + switch (state) { + case 'launching': + // First, create an entity toolbar view. + entityToolbarView = new Drupal.edit.EntityToolbarView({ + model: entityModel, + appModel: this.model + }); + entityModel.toolbarView = entityToolbarView; + // Second, set up in-place editors. + // They must be notified of state changes, hence this must happen while + // the associated fields are still in the 'inactive' state. + entityModel.get('fields').each(function (fieldModel) { + app.setupEditor(fieldModel); + }); + // Third, transition the entity to the 'opening' state, which will + // transition all fields from 'inactive' to 'candidate'. + _.defer(function () { + entityModel.set('state', 'opening'); + }); + break; + case 'closed': + entityToolbarView = entityModel.toolbarView; + // First, tear down the entity toolbar view. + // @todo @jesse Why shouldn't this happen last? + if (entityToolbarView) { + entityToolbarView.remove(); + delete entityModel.toolbarView; + } + // Second, tear down the in-place editors. + entityModel.get('fields').each(function (fieldModel) { + app.teardownEditor(fieldModel); + }); + break; } }, @@ -85,10 +98,10 @@ Drupal.edit.AppView = Backbone.View.extend({ * The new state. * @param null|Object context * The context that is trying to trigger the state change. - * @param Function callback - * The callback function that should receive the state acceptance result. + * @param Drupal.edit.FieldModel fieldModel + * The fieldModel to which this change applies. */ - acceptEditorStateChange: function (from, to, context, callback) { + acceptEditorStateChange: function (from, to, context, fieldModel) { var accept = true; // If the app is in view mode, then reject all state changes except for @@ -106,39 +119,79 @@ Drupal.edit.AppView = Backbone.View.extend({ if (!Drupal.edit.FieldModel.followsStateSequence(from, to)) { accept = false; // Allow: activating/active -> candidate. - // Necessary to stop editing a property. + // Necessary to stop editing a field. 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. + // Necessary to stop editing a field 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. + // Necessary to stop highlighting a field. else if (from === 'highlighted' && to === 'candidate') { accept = true; } // Allow: saved -> candidate. - // Necessary when successfully saved a property. + // Necessary when successfully saved a field. else if (from === 'saved' && to === 'candidate') { accept = true; } // Allow: invalid -> saving. - // Necessary to be able to save a corrected, invalid property. + // Necessary to be able to save a corrected, invalid field. else if (from === 'invalid' && to === 'saving') { accept = true; } + // Allow: invalid -> activating. + // Necessary to be able to correct a field that turned out to be invalid + // after the user already had moved on to the next field (which we + // explicitly allow to have a fluent UX). + else if (from === 'invalid' && to === 'activating') { + 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; + var activeEditor, activeEditorState; + // Ensure only one editor (field) at a time is active … but allow a user + // to hop from one field to the next, even if we still have to start + // saving the field that is currently active: assume it will be valid, + // to allow for a fluent UX. (If it turns out to be invalid, this block + // of code also handles that.) + if ((this.fieldReadyStates.indexOf(from) !== -1 || from === 'invalid') && this.activeEditorStates.indexOf(to) !== -1) { + activeEditor = this.model.get('activeEditor'); + if (activeEditor && activeEditor !== fieldModel) { + activeEditorState = activeEditor.get('state'); + // Allow the state change. If the state of the active editor is: + // - 'activating' or 'active': change it to 'candidate' + // - 'changed' or 'invalid': change it to 'saving' + // - 'saving'or 'saved': don't do anything. + if (this.activeEditorStates.indexOf(activeEditorState) !== -1) { + activeEditor.set('state', 'candidate'); + } + else if (activeEditorState === 'changed' || activeEditorState === 'invalid') { + activeEditor.set('state', 'saving'); + } + + // If the field that's being activated is in fact already in the + // invalid state (which can only happen because above we allowed the + // user to move on to another field to allow for a fluent UX; we + // assumed it would be saved successfully), then we shouldn't allow + // the field to enter the 'activating' state, instead, we simply + // change the active editor. All guarantees and assumptions for this + // field still hold! + if (from === 'invalid') { + this.model.set('activeEditor', fieldModel); + accept = false; + } + else { + // Do not reject: the field is either in the 'candidate' or + // 'highlighted' state and we allow it to enter the 'activating' + // state! + } } } // Reject going from activating/active to candidate because of a @@ -165,7 +218,7 @@ 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(); } } } @@ -184,6 +237,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 entityToolbarView = entityModel.toolbarView; + // Get the field toolbar DOM root from the entity toolbar. + var fieldToolbarRoot = entityToolbarView.getToolbarRoot(); // Create in-place editor. var editorName = fieldModel.get('metadata').editor; var editorModel = new Drupal.edit.EditorModel(); @@ -196,9 +254,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 +269,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 +314,7 @@ Drupal.edit.AppView = Backbone.View.extend({ * @see acceptEditorStateChange() */ _confirmStopEditing: function () { + var activeEntity = Drupal.edit.collections.entities.where({ isActive: true })[0]; // Only instantiate if there isn't a modal instance visible yet. if (!this.model.get('activeModal')) { var that = this; @@ -259,15 +322,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', 'committing', {confirmed : true}); + } + else { + activeEntity.get('fields').each(function (fieldModel) { + fieldModel.set('state', 'candidate', {confirmed : true}); + }); + } } }); this.model.set('activeModal', modal); @@ -310,6 +389,36 @@ 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') { + // Attempt to save the field. + activeEditor.set('state', 'saving'); + } + // 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 @@ -351,7 +460,7 @@ Drupal.edit.AppView = Backbone.View.extend({ * A field that was just added to the collection of fields. */ rerenderedFieldToCandidate: function (fieldModel) { - var activeEntity = Drupal.edit.collections.entities.where({ isActive: true })[0]; + var activeEntity = Drupal.edit.collections.entities.where({isActive: true})[0]; // Early-return if there is no active entity. if (activeEntity === null) { @@ -389,4 +498,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..3f8e144 100644 --- a/core/modules/edit/js/views/EditorDecorationView.js +++ b/core/modules/edit/js/views/EditorDecorationView.js @@ -1,13 +1,15 @@ /** * @file * A Backbone View that decorates the in-place edited element. + * + * @todo Rename to FieldDecorationView? This is not decorating the in-place + * editor, but the in-place edited element, i.e.: the field. */ (function ($, Backbone, Drupal) { "use strict"; Drupal.edit.EditorDecorationView = Backbone.View.extend({ - toolbarId: null, _widthAttributeIsEmpty: null, @@ -25,15 +27,12 @@ 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); + this.model.on('change:isChanged change:inTempStore', this.renderChanged, this); }, /** @@ -65,6 +64,7 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ if (from !== 'inactive') { this.stopHighlight(); if (from !== 'highlighted') { + this.model.set('isChanged', false); this.stopEdit(); } } @@ -84,6 +84,7 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ this.startEdit(); break; case 'changed': + this.model.set('isChanged', true); break; case 'saving': break; @@ -95,16 +96,23 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ }, /** + * Adds a class to the edited element that indicates whether the field has + * been changed by the user (i.e. locally) or the field has already been + * changed and stored before by the user (i.e. remotely, stored in TempStore). + */ + renderChanged: function () { + this.$el.toggleClass('edit-changed', this.model.get('isChanged') || this.model.get('inTempStore')); + }, + + /** * Starts hover; transitions to 'highlight' state. * * @param jQuery event */ 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 +122,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(); }, /** @@ -135,7 +141,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 +175,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'); }, /** @@ -332,26 +335,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..fa71ada 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); }, @@ -186,7 +187,14 @@ Drupal.edit.EditorView = Backbone.View.extend({ var formOptions = { fieldID: this.fieldModel.id, $el: this.$el, - nocssjs: true + nocssjs: true, + // Reset an existing entry for this entity in the TempStore (if any) when + // saving the field. Logically speaking, this should happen in a separate + // request because this is an entity-level operation, not a field-level + // operation. But that would require an additional request, that might not + // even be necessary: it is only when a user saves a first changed field + // for an entity that this needs to happen: precisely now! + reset: !this.fieldModel.get('entity').get('inTempStore') }; Drupal.edit.util.form.load(formOptions, function (form, ajax) { // Create a backstage area for storing forms that are hidden from view @@ -201,15 +209,14 @@ Drupal.edit.EditorView = Backbone.View.extend({ // forms.) $('#edit_backstage form').prop('novalidate', true); var $submit = $('#edit_backstage form .edit-form-submit'); - var base = Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); + var formSaveAjax = Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); function removeHiddenForm () { - Drupal.edit.util.form.unajaxifySaving($submit); $('#edit_backstage').remove(); } // Successfully saved. - Drupal.ajax[base].commands.editFieldFormSaved = function (ajax, response, status) { + formSaveAjax.commands.editFieldFormSaved = function (ajax, response, status) { removeHiddenForm(); // First, transition the state to 'saved'. @@ -220,7 +227,7 @@ Drupal.edit.EditorView = Backbone.View.extend({ }; // Unsuccessfully saved; validation errors. - Drupal.ajax[base].commands.editFieldFormValidationErrors = function (ajax, response, status) { + formSaveAjax.commands.editFieldFormValidationErrors = function (ajax, response, status) { removeHiddenForm(); editorModel.set('validationErrors', response.data); @@ -232,7 +239,7 @@ Drupal.edit.EditorView = Backbone.View.extend({ // Form API then marks which form items have errors. This is useful for // the form-based in-place editor, but pointless for any other: the form // itself won't be visible at all anyway! So, we just ignore it. - Drupal.ajax[base].commands.editFieldForm = function () {}; + formSaveAjax.commands.editFieldForm = function () {}; fillAndSubmitForm(editorModel.get('currentValue')); }); @@ -246,7 +253,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,7 +267,7 @@ 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(); diff --git a/core/modules/edit/js/views/EntityToolbarView.js b/core/modules/edit/js/views/EntityToolbarView.js new file mode 100644 index 0000000..c87fbdb --- /dev/null +++ b/core/modules/edit/js/views/EntityToolbarView.js @@ -0,0 +1,386 @@ +/** + * @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) { + var that = this; + this.appModel = options.appModel; + this.$entity = $(this.model.get('el')); + + // Rerender whenever the entity state changes. + this.model.on('change:isActive change:isDirty change:state', this.render, this); + // Also rerender whenever the highlighted or active in-place editor changes. + this.appModel.on('change:highlightedEditor change:activeEditor', this.render, this); + // Rerender when a field of the entity changes state. + this.model.get('fields').on('change:state', this.fieldStateChange, this); + + // Reposition the entity toolbar as the viewport and the position within the + // viewport changes. + $(window).on('resize.edit scroll.edit', debounce($.proxy(this.windowChangeHandler, this), 150)); + + // Adjust the fence placement within which the entity toolbar may be + // positioned. + $(document).on('drupalViewportOffsetChange.edit', function (event, offsets) { + (that.$fence && that.$fence.css(offsets)); + }); + + // Set the entity toolbar DOM element as the el for this view. + var $toolbar = this.buildToolbarEl(); + this.setElement($toolbar); + this._fieldToolbarRoot = $toolbar.find('.edit-toolbar-field').get(0); + + // Initial render. + this.render(); + }, + + /** + * {@inheritdoc} + */ + render: function () { + 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); + // The fence will define a area on the screen that the entity toolbar + // will be position within. + this.$fence = $(Drupal.theme('editEntityToolbarFence')) + .css(Drupal.displace()) + .appendTo('body'); + } + // 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': + // 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') + .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; + }, + + /** + * {@inheritdoc} + */ + remove: function () { + this.$fence.remove(); + Backbone.View.prototype.remove.call(this); + }, + + /** + * Repositions the entity toolbar on window scroll and resize. + * + * @param jQuery.Eevent event + */ + windowChangeHandler: function (event) { + this.position(); + }, + + /** + * Determines the actions to take given a change of state. + * + * @param Drupal.edit.FieldModel model + * @param String state + * The state of the associated field. One of Drupal.edit.FieldModel.states. + */ + fieldStateChange: function (model, state) { + switch (state) { + case 'active': + this.render(); + break; + case 'invalid': + this.render(); + break; + } + }, + + /** + * 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'; + // A time unit to wait until the entity toolbar is repositioned. + var delay = 0; + // Determines what check in the series of checks below should be evaluated + var check = 0; + var of, activeField, highlightedField; + // There are several elements in the page that the entity toolbar might be + // positioned against. They are considered below in a priority order. + do { + switch (check) { + case 0: + // Position against a specific element. + of = element; + break; + case 1: + // Position against a form container. + activeField = Drupal.edit.app.model.get('activeEditor') + of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.edit-form'); + break; + case 2: + // Position against an active field. + of = activeField && activeField.editorView && activeField.editorView.getEditedElement(); + break; + case 3: + // Position against a highlighted field. + highlightedField = Drupal.edit.app.model.get('highlightedEditor') + of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement(); + delay = 250; + break; + default: + // Position against the entity, or as a last resort, the body element. + of = this.$entity || 'body'; + delay = 0; + break; + } + // Prepare to check the next possible element to position against. + check++; + } while (!of); + // 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 () { + // Render the position in the next execution cycle, so that animations on + // the field have time to process. This is not strictly speaking, a + // guarantee that all animations will be finished, but it's a simple way + // to get better positioning without too much additional code. + _.defer(function () { + that.$el + .position({ + my: edge + '-2 bottom', + at: edge + ' top', + of: of, + collision: 'flipfit', + using: function (suggested, info) { + info.element.element.css({ + left: Math.floor(suggested.left), + top: Math.floor(suggested.top) + }); + }, + within: that.$fence + }) + // 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(), + // Set a minimum width of 240px for the entity toolbar, or the width + // of the client if it is less than 240px, so that the toolbar + // never folds up into a squashed and jumbled mess. + 'min-width': (document.documentElement.clientWidth < 240) ? document.documentElement.clientWidth : 240, + 'width': '100%' + }); + }); + }, delay); + }, + + /** + * 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. + * + * Without this, it may reposition itself, away from the user's cursor! + * + * @param jQuery event + */ + onMouseenter: function (event) { + clearTimeout(this.timer); + }, + + /** + * Builds the entity toolbar HTML; attaches to DOM; sets starting position. + */ + 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 animate + // on to the screen from a far off corner. + $toolbar + .css({ + left: this.$entity.offset().left, + top: this.$entity.offset().top + }); + + return $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..b261a7b 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,22 @@ 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. + // @todo @Jesse I think this if statement can be removed, because the entity + // toolbar positions its correctly above any element, including + // display:inline ones presumably. 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; @@ -76,245 +67,61 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ var to = state; switch (to) { case 'inactive': - if (from) { - this.remove(); - } 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(); - } + // Remove the view's existing element if we went to the 'activating' + // state or later, because it will be recreated. Not doing this would + // result in memory leaks. + if (from !== 'inactive' && from !== 'highlighted') { + this.$el.remove(); + this.setElement(); } 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 +161,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 ee9c269..8d2bcb0 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..7baaf5b 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 in the given language. + */ + 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..32fa0e0 100644 --- a/core/modules/editor/js/editor.formattedTextEditor.js +++ b/core/modules/editor/js/editor.formattedTextEditor.js @@ -163,7 +163,7 @@ Drupal.edit.editors.editor = Drupal.edit.EditorView.extend({ var fieldID = this.fieldModel.id; // Create a Drupal.ajax instance to load the form. - Drupal.ajax[fieldID] = new Drupal.ajax(fieldID, this.$el, { + var textLoaderAjax = new Drupal.ajax(fieldID, this.$el, { url: Drupal.edit.util.buildUrl(fieldID, drupalSettings.editor.getUntransformedTextURL), event: 'editor-internal.editor', submit: { nocssjs : true }, @@ -172,11 +172,8 @@ Drupal.edit.editors.editor = Drupal.edit.EditorView.extend({ // Implement a scoped editorGetUntransformedText AJAX command: calls the // callback. - Drupal.ajax[fieldID].commands.editorGetUntransformedText = function (ajax, response, status) { + textLoaderAjax.commands.editorGetUntransformedText = function (ajax, response, status) { callback(response.data); - // Delete the Drupal.ajax instance that called this very function. - delete Drupal.ajax[fieldID]; - this.$el.off('editor-internal.editor'); }; // This will ensure our scoped editorGetUntransformedText AJAX command -- 1.7.10.4