From 8287017e034bc323dec1d86b3f37a804aa082d2d Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Fri, 19 May 2017 23:12:53 +0100 Subject: [PATCH] Issue #2818825 by drpal, nod_, droplet, cilefen: Rename all JS files to *.es6.js and compile them --- core/misc/active-link.es6.js | 68 + core/misc/active-link.js | 51 +- core/misc/ajax.es6.js | 1344 ++++++++++++++ core/misc/ajax.js | 873 +-------- core/misc/announce.es6.js | 120 ++ core/misc/announce.js | 85 +- core/misc/autocomplete.es6.js | 288 +++ core/misc/autocomplete.js | 175 +- core/misc/batch.es6.js | 46 + core/misc/batch.js | 24 +- core/misc/collapse.es6.js | 146 ++ core/misc/collapse.js | 107 +- core/misc/date.es6.js | 56 + core/misc/date.js | 40 +- core/misc/debounce.es6.js | 52 + core/misc/debounce.js | 36 +- core/misc/details-aria.es6.js | 29 + core/misc/details-aria.js | 19 +- core/misc/dialog/dialog.ajax.es6.js | 246 +++ core/misc/dialog/dialog.ajax.js | 139 +- core/misc/dialog/dialog.es6.js | 100 ++ core/misc/dialog/dialog.jquery-ui.es6.js | 36 + core/misc/dialog/dialog.jquery-ui.js | 14 +- core/misc/dialog/dialog.js | 70 +- core/misc/dialog/dialog.position.es6.js | 112 ++ core/misc/dialog/dialog.position.js | 71 +- core/misc/displace.es6.js | 222 +++ core/misc/displace.js | 144 +- core/misc/dropbutton/dropbutton.es6.js | 233 +++ core/misc/dropbutton/dropbutton.js | 189 +- core/misc/drupal.es6.js | 583 ++++++ core/misc/drupal.init.es6.js | 19 + core/misc/drupal.init.js | 21 +- core/misc/drupal.js | 434 +---- core/misc/drupalSettingsLoader.es6.js | 25 + core/misc/drupalSettingsLoader.js | 19 +- core/misc/entity-form.es6.js | 57 + core/misc/entity-form.js | 34 +- core/misc/form.es6.js | 250 +++ core/misc/form.js | 175 +- core/misc/machine-name.es6.js | 211 +++ core/misc/machine-name.js | 113 +- core/misc/progress.es6.js | 169 ++ core/misc/progress.js | 118 +- core/misc/states.es6.js | 724 ++++++++ core/misc/states.js | 474 +---- core/misc/tabbingmanager.es6.js | 369 ++++ core/misc/tabbingmanager.js | 266 +-- core/misc/tabledrag.es6.js | 1557 +++++++++++++++++ core/misc/tabledrag.js | 865 ++------- core/misc/tableheader.es6.js | 316 ++++ core/misc/tableheader.js | 201 +-- core/misc/tableresponsive.es6.js | 174 ++ core/misc/tableresponsive.js | 157 +- core/misc/tableselect.es6.js | 159 ++ core/misc/tableselect.js | 93 +- core/misc/timezone.es6.js | 76 + core/misc/timezone.js | 55 +- core/misc/vertical-tabs.es6.js | 252 +++ core/misc/vertical-tabs.js | 183 +- core/modules/big_pipe/js/big_pipe.es6.js | 110 ++ core/modules/big_pipe/js/big_pipe.js | 64 +- core/modules/block/js/block.admin.es6.js | 97 + core/modules/block/js/block.admin.js | 61 +- core/modules/block/js/block.es6.js | 228 +++ core/modules/block/js/block.js | 176 +- core/modules/book/book.es6.js | 37 + core/modules/book/book.js | 28 +- .../modules/ckeditor/js/ckeditor.admin.es6.js | 499 ++++++ core/modules/ckeditor/js/ckeditor.admin.js | 349 +--- .../js/ckeditor.drupalimage.admin.es6.js | 45 + .../ckeditor/js/ckeditor.drupalimage.admin.js | 26 +- core/modules/ckeditor/js/ckeditor.es6.js | 350 ++++ core/modules/ckeditor/js/ckeditor.js | 178 +- .../js/ckeditor.language.admin.es6.js | 16 + .../ckeditor/js/ckeditor.language.admin.js | 16 +- .../js/ckeditor.stylescombo.admin.es6.js | 128 ++ .../ckeditor/js/ckeditor.stylescombo.admin.js | 96 +- core/modules/ckeditor/js/models/Model.es6.js | 75 + core/modules/ckeditor/js/models/Model.js | 61 +- .../js/plugins/drupalimage/plugin.es6.js | 371 ++++ .../ckeditor/js/plugins/drupalimage/plugin.js | 153 +- .../plugins/drupalimagecaption/plugin.es6.js | 301 ++++ .../js/plugins/drupalimagecaption/plugin.js | 117 +- .../js/plugins/drupallink/plugin.es6.js | 304 ++++ .../ckeditor/js/plugins/drupallink/plugin.js | 121 +- .../ckeditor/js/views/AuralView.es6.js | 233 +++ core/modules/ckeditor/js/views/AuralView.js | 152 +- .../ckeditor/js/views/ControllerView.es6.js | 383 ++++ .../ckeditor/js/views/ControllerView.js | 295 +--- .../ckeditor/js/views/KeyboardView.es6.js | 266 +++ .../modules/ckeditor/js/views/KeyboardView.js | 249 +-- .../ckeditor/js/views/VisualView.es6.js | 273 +++ core/modules/ckeditor/js/views/VisualView.js | 182 +- .../ckeditor/tests/modules/js/ajax-css.es6.js | 24 + .../ckeditor/tests/modules/js/ajax-css.js | 25 +- core/modules/color/color.es6.js | 297 ++++ core/modules/color/color.js | 208 +-- core/modules/color/preview.es6.js | 74 + core/modules/color/preview.js | 41 +- .../js/color_test_theme-fontsize.es6.js | 9 + .../js/color_test_theme-fontsize.js | 12 +- .../comment/comment-entity-form.es6.js | 23 + core/modules/comment/comment-entity-form.js | 18 +- .../comment/js/comment-by-viewer.es6.js | 26 + core/modules/comment/js/comment-by-viewer.js | 27 +- .../comment/js/comment-new-indicator.es6.js | 96 + .../comment/js/comment-new-indicator.js | 74 +- .../comment/js/node-new-comments-link.es6.js | 177 ++ .../comment/js/node-new-comments-link.js | 140 +- .../content_translation.admin.es6.js | 131 ++ .../content_translation.admin.js | 79 +- core/modules/contextual/js/contextual.es6.js | 256 +++ core/modules/contextual/js/contextual.js | 153 +- .../contextual/js/contextual.toolbar.es6.js | 77 + .../contextual/js/contextual.toolbar.js | 42 +- .../contextual/js/models/StateModel.es6.js | 132 ++ .../contextual/js/models/StateModel.js | 99 +- .../js/toolbar/models/StateModel.es6.js | 119 ++ .../js/toolbar/models/StateModel.js | 93 +- .../js/toolbar/views/AuralView.es6.js | 104 ++ .../contextual/js/toolbar/views/AuralView.js | 70 +- .../js/toolbar/views/VisualView.es6.js | 84 + .../contextual/js/toolbar/views/VisualView.js | 66 +- .../contextual/js/views/AuralView.es6.js | 55 + core/modules/contextual/js/views/AuralView.js | 51 +- .../contextual/js/views/KeyboardView.es6.js | 61 + .../contextual/js/views/KeyboardView.js | 51 +- .../contextual/js/views/RegionView.es6.js | 57 + .../modules/contextual/js/views/RegionView.js | 48 +- .../contextual/js/views/VisualView.es6.js | 80 + .../modules/contextual/js/views/VisualView.js | 73 +- core/modules/editor/js/editor.admin.es6.js | 935 ++++++++++ core/modules/editor/js/editor.admin.js | 680 +------ core/modules/editor/js/editor.dialog.es6.js | 34 + core/modules/editor/js/editor.dialog.js | 32 +- core/modules/editor/js/editor.es6.js | 318 ++++ .../js/editor.formattedTextEditor.es6.js | 231 +++ .../editor/js/editor.formattedTextEditor.js | 146 +- core/modules/editor/js/editor.js | 231 +-- core/modules/field_ui/field_ui.es6.js | 335 ++++ core/modules/field_ui/field_ui.js | 201 +-- core/modules/file/file.es6.js | 257 +++ core/modules/file/file.js | 163 +- core/modules/filter/filter.admin.es6.js | 69 + core/modules/filter/filter.admin.js | 43 +- core/modules/filter/filter.es6.js | 39 + .../filter/filter.filter_html.admin.es6.js | 328 ++++ .../filter/filter.filter_html.admin.js | 236 +-- core/modules/filter/filter.js | 34 +- core/modules/history/js/history.es6.js | 134 ++ core/modules/history/js/history.js | 82 +- core/modules/history/js/mark-as-read.es6.js | 23 + core/modules/history/js/mark-as-read.js | 19 +- core/modules/image/js/editors/image.es6.js | 342 ++++ core/modules/image/js/editors/image.js | 192 +- core/modules/image/js/theme.es6.js | 86 + core/modules/image/js/theme.js | 73 +- core/modules/language/language.admin.es6.js | 43 + core/modules/language/language.admin.js | 34 +- core/modules/locale/locale.admin.es6.js | 116 ++ core/modules/locale/locale.admin.js | 67 +- core/modules/locale/locale.bulk.es6.js | 38 + core/modules/locale/locale.bulk.js | 39 +- core/modules/locale/locale.datepicker.es6.js | 88 + core/modules/locale/locale.datepicker.js | 82 +- core/modules/locale/tests/locale_test.es6.js | 52 + core/modules/locale/tests/locale_test.js | 45 +- core/modules/media/js/media_form.es6.js | 40 + core/modules/media/js/media_form.js | 34 +- core/modules/media/js/media_type_form.es6.js | 46 + core/modules/media/js/media_type_form.js | 24 +- core/modules/menu_ui/menu_ui.admin.es6.js | 68 + core/modules/menu_ui/menu_ui.admin.js | 40 +- core/modules/menu_ui/menu_ui.es6.js | 91 + core/modules/menu_ui/menu_ui.js | 55 +- core/modules/node/content_types.es6.js | 62 + core/modules/node/content_types.js | 24 +- core/modules/node/node.es6.js | 55 + core/modules/node/node.js | 37 +- core/modules/node/node.preview.es6.js | 99 ++ core/modules/node/node.preview.js | 75 +- core/modules/outside_in/js/off-canvas.es6.js | 160 ++ core/modules/outside_in/js/off-canvas.js | 91 +- core/modules/outside_in/js/outside_in.es6.js | 265 +++ core/modules/outside_in/js/outside_in.js | 225 +-- core/modules/path/path.es6.js | 29 + core/modules/path/path.js | 27 +- .../quickedit/js/editors/formEditor.es6.js | 255 +++ .../quickedit/js/editors/formEditor.js | 154 +- .../js/editors/plainTextEditor.es6.js | 144 ++ .../quickedit/js/editors/plainTextEditor.js | 71 +- .../quickedit/js/models/AppModel.es6.js | 57 + core/modules/quickedit/js/models/AppModel.js | 52 +- .../quickedit/js/models/BaseModel.es6.js | 60 + core/modules/quickedit/js/models/BaseModel.js | 51 +- .../quickedit/js/models/EditorModel.es6.js | 54 + .../quickedit/js/models/EditorModel.js | 49 +- .../quickedit/js/models/EntityModel.es6.js | 741 ++++++++ .../quickedit/js/models/EntityModel.js | 571 +----- .../quickedit/js/models/FieldModel.es6.js | 348 ++++ .../modules/quickedit/js/models/FieldModel.js | 292 +--- core/modules/quickedit/js/quickedit.es6.js | 686 ++++++++ core/modules/quickedit/js/quickedit.js | 466 +---- core/modules/quickedit/js/theme.es6.js | 187 ++ core/modules/quickedit/js/theme.js | 118 +- core/modules/quickedit/js/util.es6.js | 213 +++ core/modules/quickedit/js/util.js | 151 +- .../modules/quickedit/js/views/AppView.es6.js | 600 +++++++ core/modules/quickedit/js/views/AppView.js | 499 ++---- .../js/views/ContextualLinkView.es6.js | 81 + .../quickedit/js/views/ContextualLinkView.js | 63 +- .../quickedit/js/views/EditorView.es6.js | 304 ++++ core/modules/quickedit/js/views/EditorView.js | 217 +-- .../js/views/EntityDecorationView.es6.js | 40 + .../js/views/EntityDecorationView.js | 34 +- .../js/views/EntityToolbarView.es6.js | 528 ++++++ .../quickedit/js/views/EntityToolbarView.js | 388 +--- .../js/views/FieldDecorationView.es6.js | 360 ++++ .../quickedit/js/views/FieldDecorationView.js | 238 +-- .../js/views/FieldToolbarView.es6.js | 227 +++ .../quickedit/js/views/FieldToolbarView.js | 154 +- .../js/responsive_image.ajax.es6.js | 16 + .../js/responsive_image.ajax.js | 16 +- core/modules/simpletest/simpletest.es6.js | 130 ++ core/modules/simpletest/simpletest.js | 81 +- core/modules/statistics/statistics.es6.js | 18 + core/modules/statistics/statistics.js | 11 +- core/modules/system/js/system.date.es6.js | 57 + core/modules/system/js/system.date.js | 36 +- core/modules/system/js/system.es6.js | 81 + core/modules/system/js/system.js | 57 +- core/modules/system/js/system.modules.es6.js | 103 ++ core/modules/system/js/system.modules.js | 53 +- ...ebassert_test.wait_for_ajax_request.es6.js | 22 + ...js_webassert_test.wait_for_ajax_request.js | 20 +- .../js_webassert_test.wait_for_element.es6.js | 22 + .../js/js_webassert_test.wait_for_element.js | 20 +- .../twig_theme_test/twig_theme_test.es6.js | 6 + .../twig_theme_test/twig_theme_test.js | 11 +- .../themes/test_theme/js/collapse.es6.js | 4 + .../tests/themes/test_theme/js/collapse.js | 9 +- core/modules/taxonomy/taxonomy.es6.js | 56 + core/modules/taxonomy/taxonomy.js | 28 +- core/modules/text/text.es6.js | 61 + core/modules/text/text.js | 29 +- core/modules/toolbar/js/escapeAdmin.es6.js | 48 + core/modules/toolbar/js/escapeAdmin.js | 32 +- .../toolbar/js/models/MenuModel.es6.js | 33 + core/modules/toolbar/js/models/MenuModel.js | 33 +- .../toolbar/js/models/ToolbarModel.es6.js | 157 ++ .../modules/toolbar/js/models/ToolbarModel.js | 128 +- core/modules/toolbar/js/toolbar.es6.js | 257 +++ core/modules/toolbar/js/toolbar.js | 185 +- core/modules/toolbar/js/toolbar.menu.es6.js | 197 +++ core/modules/toolbar/js/toolbar.menu.js | 133 +- .../toolbar/js/views/BodyVisualView.es6.js | 53 + .../toolbar/js/views/BodyVisualView.js | 48 +- .../toolbar/js/views/MenuVisualView.es6.js | 46 + .../toolbar/js/views/MenuVisualView.js | 42 +- .../toolbar/js/views/ToolbarAuralView.es6.js | 70 + .../toolbar/js/views/ToolbarAuralView.js | 58 +- .../toolbar/js/views/ToolbarVisualView.es6.js | 305 ++++ .../toolbar/js/views/ToolbarVisualView.js | 243 +-- core/modules/tour/js/tour.es6.js | 270 +++ core/modules/tour/js/tour.js | 231 +-- .../modules/tracker/js/tracker-history.es6.js | 122 ++ core/modules/tracker/js/tracker-history.js | 94 +- core/modules/user/user.es6.js | 217 +++ core/modules/user/user.js | 101 +- core/modules/user/user.permissions.es6.js | 88 + core/modules/user/user.permissions.js | 69 +- core/modules/views/js/ajax_view.es6.js | 205 +++ core/modules/views/js/ajax_view.js | 122 +- core/modules/views/js/base.es6.js | 110 ++ core/modules/views/js/base.js | 63 +- .../views_test_data/views_cache.test.es6.js | 8 + .../views_test_data/views_cache.test.js | 13 +- core/modules/views_ui/js/ajax.es6.js | 249 +++ core/modules/views_ui/js/ajax.js | 206 +-- core/modules/views_ui/js/dialog.views.es6.js | 58 + core/modules/views_ui/js/dialog.views.js | 40 +- core/modules/views_ui/js/views-admin.es6.js | 1192 +++++++++++++ core/modules/views_ui/js/views-admin.js | 739 ++------ .../views_ui/js/views_ui.listing.es6.js | 54 + core/modules/views_ui/js/views_ui.listing.js | 30 +- core/package.json | 1 + core/scripts/js/changeOrAdded.js | 9 +- core/scripts/js/rename-js-files-to-es6.sh | 12 - core/themes/bartik/color/preview.es6.js | 49 + core/themes/bartik/color/preview.js | 22 +- core/themes/seven/js/mobile.install.es6.js | 33 + core/themes/seven/js/mobile.install.js | 13 +- core/themes/seven/js/nav-tabs.es6.js | 55 + core/themes/seven/js/nav-tabs.js | 21 +- .../themes/seven/js/responsive-details.es6.js | 57 + core/themes/seven/js/responsive-details.js | 37 +- core/yarn.lock | 4 + 298 files changed, 31056 insertions(+), 14996 deletions(-) create mode 100644 core/misc/active-link.es6.js create mode 100644 core/misc/ajax.es6.js create mode 100644 core/misc/announce.es6.js create mode 100644 core/misc/autocomplete.es6.js create mode 100644 core/misc/batch.es6.js create mode 100644 core/misc/collapse.es6.js create mode 100644 core/misc/date.es6.js create mode 100644 core/misc/debounce.es6.js create mode 100644 core/misc/details-aria.es6.js create mode 100644 core/misc/dialog/dialog.ajax.es6.js create mode 100644 core/misc/dialog/dialog.es6.js create mode 100644 core/misc/dialog/dialog.jquery-ui.es6.js create mode 100644 core/misc/dialog/dialog.position.es6.js create mode 100644 core/misc/displace.es6.js create mode 100644 core/misc/dropbutton/dropbutton.es6.js create mode 100644 core/misc/drupal.es6.js create mode 100644 core/misc/drupal.init.es6.js create mode 100644 core/misc/drupalSettingsLoader.es6.js create mode 100644 core/misc/entity-form.es6.js create mode 100644 core/misc/form.es6.js create mode 100644 core/misc/machine-name.es6.js create mode 100644 core/misc/progress.es6.js create mode 100644 core/misc/states.es6.js create mode 100644 core/misc/tabbingmanager.es6.js create mode 100644 core/misc/tabledrag.es6.js create mode 100644 core/misc/tableheader.es6.js create mode 100644 core/misc/tableresponsive.es6.js create mode 100644 core/misc/tableselect.es6.js create mode 100644 core/misc/timezone.es6.js create mode 100644 core/misc/vertical-tabs.es6.js create mode 100644 core/modules/big_pipe/js/big_pipe.es6.js create mode 100644 core/modules/block/js/block.admin.es6.js create mode 100644 core/modules/block/js/block.es6.js create mode 100644 core/modules/book/book.es6.js create mode 100644 core/modules/ckeditor/js/ckeditor.admin.es6.js create mode 100644 core/modules/ckeditor/js/ckeditor.drupalimage.admin.es6.js create mode 100644 core/modules/ckeditor/js/ckeditor.es6.js create mode 100644 core/modules/ckeditor/js/ckeditor.language.admin.es6.js create mode 100644 core/modules/ckeditor/js/ckeditor.stylescombo.admin.es6.js create mode 100644 core/modules/ckeditor/js/models/Model.es6.js create mode 100644 core/modules/ckeditor/js/plugins/drupalimage/plugin.es6.js create mode 100644 core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.es6.js create mode 100644 core/modules/ckeditor/js/plugins/drupallink/plugin.es6.js create mode 100644 core/modules/ckeditor/js/views/AuralView.es6.js create mode 100644 core/modules/ckeditor/js/views/ControllerView.es6.js create mode 100644 core/modules/ckeditor/js/views/KeyboardView.es6.js create mode 100644 core/modules/ckeditor/js/views/VisualView.es6.js create mode 100644 core/modules/ckeditor/tests/modules/js/ajax-css.es6.js create mode 100644 core/modules/color/color.es6.js create mode 100644 core/modules/color/preview.es6.js create mode 100644 core/modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.es6.js create mode 100644 core/modules/comment/comment-entity-form.es6.js create mode 100644 core/modules/comment/js/comment-by-viewer.es6.js create mode 100644 core/modules/comment/js/comment-new-indicator.es6.js create mode 100644 core/modules/comment/js/node-new-comments-link.es6.js create mode 100644 core/modules/content_translation/content_translation.admin.es6.js create mode 100644 core/modules/contextual/js/contextual.es6.js create mode 100644 core/modules/contextual/js/contextual.toolbar.es6.js create mode 100644 core/modules/contextual/js/models/StateModel.es6.js create mode 100644 core/modules/contextual/js/toolbar/models/StateModel.es6.js create mode 100644 core/modules/contextual/js/toolbar/views/AuralView.es6.js create mode 100644 core/modules/contextual/js/toolbar/views/VisualView.es6.js create mode 100644 core/modules/contextual/js/views/AuralView.es6.js create mode 100644 core/modules/contextual/js/views/KeyboardView.es6.js create mode 100644 core/modules/contextual/js/views/RegionView.es6.js create mode 100644 core/modules/contextual/js/views/VisualView.es6.js create mode 100644 core/modules/editor/js/editor.admin.es6.js create mode 100644 core/modules/editor/js/editor.dialog.es6.js create mode 100644 core/modules/editor/js/editor.es6.js create mode 100644 core/modules/editor/js/editor.formattedTextEditor.es6.js create mode 100644 core/modules/field_ui/field_ui.es6.js create mode 100644 core/modules/file/file.es6.js create mode 100644 core/modules/filter/filter.admin.es6.js create mode 100644 core/modules/filter/filter.es6.js create mode 100644 core/modules/filter/filter.filter_html.admin.es6.js create mode 100644 core/modules/history/js/history.es6.js create mode 100644 core/modules/history/js/mark-as-read.es6.js create mode 100644 core/modules/image/js/editors/image.es6.js create mode 100644 core/modules/image/js/theme.es6.js create mode 100644 core/modules/language/language.admin.es6.js create mode 100644 core/modules/locale/locale.admin.es6.js create mode 100644 core/modules/locale/locale.bulk.es6.js create mode 100644 core/modules/locale/locale.datepicker.es6.js create mode 100644 core/modules/locale/tests/locale_test.es6.js create mode 100644 core/modules/media/js/media_form.es6.js create mode 100644 core/modules/media/js/media_type_form.es6.js create mode 100644 core/modules/menu_ui/menu_ui.admin.es6.js create mode 100644 core/modules/menu_ui/menu_ui.es6.js create mode 100644 core/modules/node/content_types.es6.js create mode 100644 core/modules/node/node.es6.js create mode 100644 core/modules/node/node.preview.es6.js create mode 100644 core/modules/outside_in/js/off-canvas.es6.js create mode 100644 core/modules/outside_in/js/outside_in.es6.js create mode 100644 core/modules/path/path.es6.js create mode 100644 core/modules/quickedit/js/editors/formEditor.es6.js create mode 100644 core/modules/quickedit/js/editors/plainTextEditor.es6.js create mode 100644 core/modules/quickedit/js/models/AppModel.es6.js create mode 100644 core/modules/quickedit/js/models/BaseModel.es6.js create mode 100644 core/modules/quickedit/js/models/EditorModel.es6.js create mode 100644 core/modules/quickedit/js/models/EntityModel.es6.js create mode 100644 core/modules/quickedit/js/models/FieldModel.es6.js create mode 100644 core/modules/quickedit/js/quickedit.es6.js create mode 100644 core/modules/quickedit/js/theme.es6.js create mode 100644 core/modules/quickedit/js/util.es6.js create mode 100644 core/modules/quickedit/js/views/AppView.es6.js create mode 100644 core/modules/quickedit/js/views/ContextualLinkView.es6.js create mode 100644 core/modules/quickedit/js/views/EditorView.es6.js create mode 100644 core/modules/quickedit/js/views/EntityDecorationView.es6.js create mode 100644 core/modules/quickedit/js/views/EntityToolbarView.es6.js create mode 100644 core/modules/quickedit/js/views/FieldDecorationView.es6.js create mode 100644 core/modules/quickedit/js/views/FieldToolbarView.es6.js create mode 100644 core/modules/responsive_image/js/responsive_image.ajax.es6.js create mode 100644 core/modules/simpletest/simpletest.es6.js create mode 100644 core/modules/statistics/statistics.es6.js create mode 100644 core/modules/system/js/system.date.es6.js create mode 100644 core/modules/system/js/system.es6.js create mode 100644 core/modules/system/js/system.modules.es6.js create mode 100644 core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.es6.js create mode 100644 core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.es6.js create mode 100644 core/modules/system/tests/modules/twig_theme_test/twig_theme_test.es6.js create mode 100644 core/modules/system/tests/themes/test_theme/js/collapse.es6.js create mode 100644 core/modules/taxonomy/taxonomy.es6.js create mode 100644 core/modules/text/text.es6.js create mode 100644 core/modules/toolbar/js/escapeAdmin.es6.js create mode 100644 core/modules/toolbar/js/models/MenuModel.es6.js create mode 100644 core/modules/toolbar/js/models/ToolbarModel.es6.js create mode 100644 core/modules/toolbar/js/toolbar.es6.js create mode 100644 core/modules/toolbar/js/toolbar.menu.es6.js create mode 100644 core/modules/toolbar/js/views/BodyVisualView.es6.js create mode 100644 core/modules/toolbar/js/views/MenuVisualView.es6.js create mode 100644 core/modules/toolbar/js/views/ToolbarAuralView.es6.js create mode 100644 core/modules/toolbar/js/views/ToolbarVisualView.es6.js create mode 100644 core/modules/tour/js/tour.es6.js create mode 100644 core/modules/tracker/js/tracker-history.es6.js create mode 100644 core/modules/user/user.es6.js create mode 100644 core/modules/user/user.permissions.es6.js create mode 100644 core/modules/views/js/ajax_view.es6.js create mode 100644 core/modules/views/js/base.es6.js create mode 100644 core/modules/views/tests/modules/views_test_data/views_cache.test.es6.js create mode 100644 core/modules/views_ui/js/ajax.es6.js create mode 100644 core/modules/views_ui/js/dialog.views.es6.js create mode 100644 core/modules/views_ui/js/views-admin.es6.js create mode 100644 core/modules/views_ui/js/views_ui.listing.es6.js delete mode 100644 core/scripts/js/rename-js-files-to-es6.sh create mode 100644 core/themes/bartik/color/preview.es6.js create mode 100644 core/themes/seven/js/mobile.install.es6.js create mode 100644 core/themes/seven/js/nav-tabs.es6.js create mode 100644 core/themes/seven/js/responsive-details.es6.js diff --git a/core/misc/active-link.es6.js b/core/misc/active-link.es6.js new file mode 100644 index 000000000000..9cf55b43444f --- /dev/null +++ b/core/misc/active-link.es6.js @@ -0,0 +1,68 @@ +/** + * @file + * Attaches behaviors for Drupal's active link marking. + */ + +(function (Drupal, drupalSettings) { + + 'use strict'; + + /** + * Append is-active class. + * + * The link is only active if its path corresponds to the current path, the + * language of the linked path is equal to the current language, and if the + * query parameters of the link equal those of the current request, since the + * same request with different query parameters may yield a different page + * (e.g. pagers, exposed View filters). + * + * Does not discriminate based on element type, so allows you to set the + * is-active class on any element: a, li… + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.activeLinks = { + attach: function (context) { + // Start by finding all potentially active links. + var path = drupalSettings.path; + var queryString = JSON.stringify(path.currentQuery); + var querySelector = path.currentQuery ? "[data-drupal-link-query='" + queryString + "']" : ':not([data-drupal-link-query])'; + var originalSelectors = ['[data-drupal-link-system-path="' + path.currentPath + '"]']; + var selectors; + + // If this is the front page, we have to check for the <front> path as + // well. + if (path.isFront) { + originalSelectors.push('[data-drupal-link-system-path="<front>"]'); + } + + // Add language filtering. + selectors = [].concat( + // Links without any hreflang attributes (most of them). + originalSelectors.map(function (selector) { return selector + ':not([hreflang])'; }), + // Links with hreflang equals to the current language. + originalSelectors.map(function (selector) { return selector + '[hreflang="' + path.currentLanguage + '"]'; }) + ); + + // Add query string selector for pagers, exposed filters. + selectors = selectors.map(function (current) { return current + querySelector; }); + + // Query the DOM. + var activeLinks = context.querySelectorAll(selectors.join(',')); + var il = activeLinks.length; + for (var i = 0; i < il; i++) { + activeLinks[i].classList.add('is-active'); + } + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + var activeLinks = context.querySelectorAll('[data-drupal-link-system-path].is-active'); + var il = activeLinks.length; + for (var i = 0; i < il; i++) { + activeLinks[i].classList.remove('is-active'); + } + } + } + }; + +})(Drupal, drupalSettings); diff --git a/core/misc/active-link.js b/core/misc/active-link.js index 9cf55b43444f..9251c4e8cb21 100644 --- a/core/misc/active-link.js +++ b/core/misc/active-link.js @@ -1,60 +1,44 @@ /** - * @file - * Attaches behaviors for Drupal's active link marking. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/active-link.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, drupalSettings) { 'use strict'; - /** - * Append is-active class. - * - * The link is only active if its path corresponds to the current path, the - * language of the linked path is equal to the current language, and if the - * query parameters of the link equal those of the current request, since the - * same request with different query parameters may yield a different page - * (e.g. pagers, exposed View filters). - * - * Does not discriminate based on element type, so allows you to set the - * is-active class on any element: a, li… - * - * @type {Drupal~behavior} - */ Drupal.behaviors.activeLinks = { - attach: function (context) { - // Start by finding all potentially active links. + attach: function attach(context) { var path = drupalSettings.path; var queryString = JSON.stringify(path.currentQuery); var querySelector = path.currentQuery ? "[data-drupal-link-query='" + queryString + "']" : ':not([data-drupal-link-query])'; var originalSelectors = ['[data-drupal-link-system-path="' + path.currentPath + '"]']; var selectors; - // If this is the front page, we have to check for the <front> path as - // well. if (path.isFront) { originalSelectors.push('[data-drupal-link-system-path="<front>"]'); } - // Add language filtering. - selectors = [].concat( - // Links without any hreflang attributes (most of them). - originalSelectors.map(function (selector) { return selector + ':not([hreflang])'; }), - // Links with hreflang equals to the current language. - originalSelectors.map(function (selector) { return selector + '[hreflang="' + path.currentLanguage + '"]'; }) - ); + selectors = [].concat(originalSelectors.map(function (selector) { + return selector + ':not([hreflang])'; + }), originalSelectors.map(function (selector) { + return selector + '[hreflang="' + path.currentLanguage + '"]'; + })); - // Add query string selector for pagers, exposed filters. - selectors = selectors.map(function (current) { return current + querySelector; }); + selectors = selectors.map(function (current) { + return current + querySelector; + }); - // Query the DOM. var activeLinks = context.querySelectorAll(selectors.join(',')); var il = activeLinks.length; for (var i = 0; i < il; i++) { activeLinks[i].classList.add('is-active'); } }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload') { var activeLinks = context.querySelectorAll('[data-drupal-link-system-path].is-active'); var il = activeLinks.length; @@ -64,5 +48,4 @@ } } }; - -})(Drupal, drupalSettings); +})(Drupal, drupalSettings); \ No newline at end of file diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js new file mode 100644 index 000000000000..fefe9f3031ef --- /dev/null +++ b/core/misc/ajax.es6.js @@ -0,0 +1,1344 @@ +/** + * @file + * Provides Ajax page updating via jQuery $.ajax. + * + * Ajax is a method of making a request via JavaScript while viewing an HTML + * page. The request returns an array of commands encoded in JSON, which is + * then executed to make any changes that are necessary to the page. + * + * Drupal uses this file to enhance form elements with `#ajax['url']` and + * `#ajax['wrapper']` properties. If set, this file will automatically be + * included to provide Ajax capabilities. + */ + +(function ($, window, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Attaches the Ajax behavior to each Ajax form element. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Initialize all {@link Drupal.Ajax} objects declared in + * `drupalSettings.ajax` or initialize {@link Drupal.Ajax} objects from + * DOM elements having the `use-ajax-submit` or `use-ajax` css class. + * @prop {Drupal~behaviorDetach} detach + * During `unload` remove all {@link Drupal.Ajax} objects related to + * the removed content. + */ + Drupal.behaviors.AJAX = { + attach: function (context, settings) { + + function loadAjaxBehavior(base) { + var element_settings = settings.ajax[base]; + if (typeof element_settings.selector === 'undefined') { + element_settings.selector = '#' + base; + } + $(element_settings.selector).once('drupal-ajax').each(function () { + element_settings.element = this; + element_settings.base = base; + Drupal.ajax(element_settings); + }); + } + + // Load all Ajax behaviors specified in the settings. + for (var base in settings.ajax) { + if (settings.ajax.hasOwnProperty(base)) { + loadAjaxBehavior(base); + } + } + + // Bind Ajax behaviors to all items showing the class. + $('.use-ajax').once('ajax').each(function () { + var element_settings = {}; + // Clicked links look better with the throbber than the progress bar. + element_settings.progress = {type: 'throbber'}; + + // For anchor tags, these will go to the target of the anchor rather + // than the usual location. + var href = $(this).attr('href'); + if (href) { + element_settings.url = href; + element_settings.event = 'click'; + } + element_settings.dialogType = $(this).data('dialog-type'); + element_settings.dialog = $(this).data('dialog-options'); + element_settings.base = $(this).attr('id'); + element_settings.element = this; + Drupal.ajax(element_settings); + }); + + // This class means to submit the form to the action using Ajax. + $('.use-ajax-submit').once('ajax').each(function () { + var element_settings = {}; + + // Ajax submits specified in this manner automatically submit to the + // normal form action. + element_settings.url = $(this.form).attr('action'); + // Form submit button clicks need to tell the form what was clicked so + // it gets passed in the POST request. + element_settings.setClick = true; + // Form buttons use the 'click' event rather than mousedown. + element_settings.event = 'click'; + // Clicked form buttons look better with the throbber than the progress + // bar. + element_settings.progress = {type: 'throbber'}; + element_settings.base = $(this).attr('id'); + element_settings.element = this; + + Drupal.ajax(element_settings); + }); + }, + + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + Drupal.ajax.expired().forEach(function (instance) { + // Set this to null and allow garbage collection to reclaim + // the memory. + Drupal.ajax.instances[instance.instanceIndex] = null; + }); + } + } + }; + + /** + * Extends Error to provide handling for Errors in Ajax. + * + * @constructor + * + * @augments Error + * + * @param {XMLHttpRequest} xmlhttp + * XMLHttpRequest object used for the failed request. + * @param {string} uri + * The URI where the error occurred. + * @param {string} customMessage + * The custom message. + */ + Drupal.AjaxError = function (xmlhttp, uri, customMessage) { + + var statusCode; + var statusText; + var pathText; + var responseText; + var readyStateText; + if (xmlhttp.status) { + statusCode = '\n' + Drupal.t('An AJAX HTTP error occurred.') + '\n' + Drupal.t('HTTP Result Code: !status', {'!status': xmlhttp.status}); + } + else { + statusCode = '\n' + Drupal.t('An AJAX HTTP request terminated abnormally.'); + } + statusCode += '\n' + Drupal.t('Debugging information follows.'); + pathText = '\n' + Drupal.t('Path: !uri', {'!uri': uri}); + statusText = ''; + // In some cases, when statusCode === 0, xmlhttp.statusText may not be + // defined. Unfortunately, testing for it with typeof, etc, doesn't seem to + // catch that and the test causes an exception. So we need to catch the + // exception here. + try { + statusText = '\n' + Drupal.t('StatusText: !statusText', {'!statusText': $.trim(xmlhttp.statusText)}); + } + catch (e) { + // Empty. + } + + responseText = ''; + // Again, we don't have a way to know for sure whether accessing + // xmlhttp.responseText is going to throw an exception. So we'll catch it. + try { + responseText = '\n' + Drupal.t('ResponseText: !responseText', {'!responseText': $.trim(xmlhttp.responseText)}); + } + catch (e) { + // Empty. + } + + // Make the responseText more readable by stripping HTML tags and newlines. + responseText = responseText.replace(/<("[^"]*"|'[^']*'|[^'">])*>/gi, ''); + responseText = responseText.replace(/[\n]+\s+/g, '\n'); + + // We don't need readyState except for status == 0. + readyStateText = xmlhttp.status === 0 ? ('\n' + Drupal.t('ReadyState: !readyState', {'!readyState': xmlhttp.readyState})) : ''; + + customMessage = customMessage ? ('\n' + Drupal.t('CustomMessage: !customMessage', {'!customMessage': customMessage})) : ''; + + /** + * Formatted and translated error message. + * + * @type {string} + */ + this.message = statusCode + pathText + statusText + customMessage + responseText + readyStateText; + + /** + * Used by some browsers to display a more accurate stack trace. + * + * @type {string} + */ + this.name = 'AjaxError'; + }; + + Drupal.AjaxError.prototype = new Error(); + Drupal.AjaxError.prototype.constructor = Drupal.AjaxError; + + /** + * Provides Ajax page updating via jQuery $.ajax. + * + * This function is designed to improve developer experience by wrapping the + * initialization of {@link Drupal.Ajax} objects and storing all created + * objects in the {@link Drupal.ajax.instances} array. + * + * @example + * Drupal.behaviors.myCustomAJAXStuff = { + * attach: function (context, settings) { + * + * var ajaxSettings = { + * url: 'my/url/path', + * // If the old version of Drupal.ajax() needs to be used those + * // properties can be added + * base: 'myBase', + * element: $(context).find('.someElement') + * }; + * + * var myAjaxObject = Drupal.ajax(ajaxSettings); + * + * // Declare a new Ajax command specifically for this Ajax object. + * myAjaxObject.commands.insert = function (ajax, response, status) { + * $('#my-wrapper').append(response.data); + * alert('New content was appended to #my-wrapper'); + * }; + * + * // This command will remove this Ajax object from the page. + * myAjaxObject.commands.destroyObject = function (ajax, response, status) { + * Drupal.ajax.instances[this.instanceIndex] = null; + * }; + * + * // Programmatically trigger the Ajax request. + * myAjaxObject.execute(); + * } + * }; + * + * @param {object} settings + * The settings object passed to {@link Drupal.Ajax} constructor. + * @param {string} [settings.base] + * Base is passed to {@link Drupal.Ajax} constructor as the 'base' + * parameter. + * @param {HTMLElement} [settings.element] + * Element parameter of {@link Drupal.Ajax} constructor, element on which + * event listeners will be bound. + * + * @return {Drupal.Ajax} + * The created Ajax object. + * + * @see Drupal.AjaxCommands + */ + Drupal.ajax = function (settings) { + if (arguments.length !== 1) { + throw new Error('Drupal.ajax() function must be called with one configuration object only'); + } + // Map those config keys to variables for the old Drupal.ajax function. + var base = settings.base || false; + var element = settings.element || false; + delete settings.base; + delete settings.element; + + // By default do not display progress for ajax calls without an element. + if (!settings.progress && !element) { + settings.progress = false; + } + + var ajax = new Drupal.Ajax(base, element, settings); + ajax.instanceIndex = Drupal.ajax.instances.length; + Drupal.ajax.instances.push(ajax); + + return ajax; + }; + + /** + * Contains all created Ajax objects. + * + * @type {Array.<Drupal.Ajax|null>} + */ + Drupal.ajax.instances = []; + + /** + * List all objects where the associated element is not in the DOM + * + * This method ignores {@link Drupal.Ajax} objects not bound to DOM elements + * when created with {@link Drupal.ajax}. + * + * @return {Array.<Drupal.Ajax>} + * The list of expired {@link Drupal.Ajax} objects. + */ + Drupal.ajax.expired = function () { + return Drupal.ajax.instances.filter(function (instance) { + return instance && instance.element !== false && !document.body.contains(instance.element); + }); + }; + + /** + * Settings for an Ajax object. + * + * @typedef {object} Drupal.Ajax~element_settings + * + * @prop {string} url + * Target of the Ajax request. + * @prop {?string} [event] + * Event bound to settings.element which will trigger the Ajax request. + * @prop {bool} [keypress=true] + * Triggers a request on keypress events. + * @prop {?string} selector + * jQuery selector targeting the element to bind events to or used with + * {@link Drupal.AjaxCommands}. + * @prop {string} [effect='none'] + * Name of the jQuery method to use for displaying new Ajax content. + * @prop {string|number} [speed='none'] + * Speed with which to apply the effect. + * @prop {string} [method] + * Name of the jQuery method used to insert new content in the targeted + * element. + * @prop {object} [progress] + * Settings for the display of a user-friendly loader. + * @prop {string} [progress.type='throbber'] + * Type of progress element, core provides `'bar'`, `'throbber'` and + * `'fullscreen'`. + * @prop {string} [progress.message=Drupal.t('Please wait...')] + * Custom message to be used with the bar indicator. + * @prop {object} [submit] + * Extra data to be sent with the Ajax request. + * @prop {bool} [submit.js=true] + * Allows the PHP side to know this comes from an Ajax request. + * @prop {object} [dialog] + * Options for {@link Drupal.dialog}. + * @prop {string} [dialogType] + * One of `'modal'` or `'dialog'`. + * @prop {string} [prevent] + * List of events on which to stop default action and stop propagation. + */ + + /** + * Ajax constructor. + * + * The Ajax request returns an array of commands encoded in JSON, which is + * then executed to make any changes that are necessary to the page. + * + * Drupal uses this file to enhance form elements with `#ajax['url']` and + * `#ajax['wrapper']` properties. If set, this file will automatically be + * included to provide Ajax capabilities. + * + * @constructor + * + * @param {string} [base] + * Base parameter of {@link Drupal.Ajax} constructor + * @param {HTMLElement} [element] + * Element parameter of {@link Drupal.Ajax} constructor, element on which + * event listeners will be bound. + * @param {Drupal.Ajax~element_settings} element_settings + * Settings for this Ajax object. + */ + Drupal.Ajax = function (base, element, element_settings) { + var defaults = { + event: element ? 'mousedown' : null, + keypress: true, + selector: base ? '#' + base : null, + effect: 'none', + speed: 'none', + method: 'replaceWith', + progress: { + type: 'throbber', + message: Drupal.t('Please wait...') + }, + submit: { + js: true + } + }; + + $.extend(this, defaults, element_settings); + + /** + * @type {Drupal.AjaxCommands} + */ + this.commands = new Drupal.AjaxCommands(); + + /** + * @type {bool|number} + */ + this.instanceIndex = false; + + // @todo Remove this after refactoring the PHP code to: + // - Call this 'selector'. + // - Include the '#' for ID-based selectors. + // - Support non-ID-based selectors. + if (this.wrapper) { + + /** + * @type {string} + */ + this.wrapper = '#' + this.wrapper; + } + + /** + * @type {HTMLElement} + */ + this.element = element; + + /** + * @type {Drupal.Ajax~element_settings} + */ + this.element_settings = element_settings; + + // If there isn't a form, jQuery.ajax() will be used instead, allowing us to + // bind Ajax to links as well. + if (this.element && this.element.form) { + + /** + * @type {jQuery} + */ + this.$form = $(this.element.form); + } + + // If no Ajax callback URL was given, use the link href or form action. + if (!this.url) { + var $element = $(this.element); + if ($element.is('a')) { + this.url = $element.attr('href'); + } + else if (this.element && element.form) { + this.url = this.$form.attr('action'); + } + } + + // Replacing 'nojs' with 'ajax' in the URL allows for an easy method to let + // the server detect when it needs to degrade gracefully. + // There are four scenarios to check for: + // 1. /nojs/ + // 2. /nojs$ - The end of a URL string. + // 3. /nojs? - Followed by a query (e.g. path/nojs?destination=foobar). + // 4. /nojs# - Followed by a fragment (e.g.: path/nojs#myfragment). + var originalUrl = this.url; + + /** + * Processed Ajax URL. + * + * @type {string} + */ + this.url = this.url.replace(/\/nojs(\/|$|\?|#)/g, '/ajax$1'); + // If the 'nojs' version of the URL is trusted, also trust the 'ajax' + // version. + if (drupalSettings.ajaxTrustedUrl[originalUrl]) { + drupalSettings.ajaxTrustedUrl[this.url] = true; + } + + // Set the options for the ajaxSubmit function. + // The 'this' variable will not persist inside of the options object. + var ajax = this; + + /** + * Options for the jQuery.ajax function. + * + * @name Drupal.Ajax#options + * + * @type {object} + * + * @prop {string} url + * Ajax URL to be called. + * @prop {object} data + * Ajax payload. + * @prop {function} beforeSerialize + * Implement jQuery beforeSerialize function to call + * {@link Drupal.Ajax#beforeSerialize}. + * @prop {function} beforeSubmit + * Implement jQuery beforeSubmit function to call + * {@link Drupal.Ajax#beforeSubmit}. + * @prop {function} beforeSend + * Implement jQuery beforeSend function to call + * {@link Drupal.Ajax#beforeSend}. + * @prop {function} success + * Implement jQuery success function to call + * {@link Drupal.Ajax#success}. + * @prop {function} complete + * Implement jQuery success function to clean up ajax state and trigger an + * error if needed. + * @prop {string} dataType='json' + * Type of the response expected. + * @prop {string} type='POST' + * HTTP method to use for the Ajax request. + */ + ajax.options = { + url: ajax.url, + data: ajax.submit, + beforeSerialize: function (element_settings, options) { + return ajax.beforeSerialize(element_settings, options); + }, + beforeSubmit: function (form_values, element_settings, options) { + ajax.ajaxing = true; + return ajax.beforeSubmit(form_values, element_settings, options); + }, + beforeSend: function (xmlhttprequest, options) { + ajax.ajaxing = true; + return ajax.beforeSend(xmlhttprequest, options); + }, + success: function (response, status, xmlhttprequest) { + // Sanity check for browser support (object expected). + // When using iFrame uploads, responses must be returned as a string. + if (typeof response === 'string') { + response = $.parseJSON(response); + } + + // Prior to invoking the response's commands, verify that they can be + // trusted by checking for a response header. See + // \Drupal\Core\EventSubscriber\AjaxResponseSubscriber for details. + // - Empty responses are harmless so can bypass verification. This + // avoids an alert message for server-generated no-op responses that + // skip Ajax rendering. + // - Ajax objects with trusted URLs (e.g., ones defined server-side via + // #ajax) can bypass header verification. This is especially useful + // for Ajax with multipart forms. Because IFRAME transport is used, + // the response headers cannot be accessed for verification. + if (response !== null && !drupalSettings.ajaxTrustedUrl[ajax.url]) { + if (xmlhttprequest.getResponseHeader('X-Drupal-Ajax-Token') !== '1') { + var customMessage = Drupal.t('The response failed verification so will not be processed.'); + return ajax.error(xmlhttprequest, ajax.url, customMessage); + } + } + + return ajax.success(response, status); + }, + complete: function (xmlhttprequest, status) { + ajax.ajaxing = false; + if (status === 'error' || status === 'parsererror') { + return ajax.error(xmlhttprequest, ajax.url); + } + }, + dataType: 'json', + type: 'POST' + }; + + if (element_settings.dialog) { + ajax.options.data.dialogOptions = element_settings.dialog; + } + + // Ensure that we have a valid URL by adding ? when no query parameter is + // yet available, otherwise append using &. + if (ajax.options.url.indexOf('?') === -1) { + ajax.options.url += '?'; + } + else { + ajax.options.url += '&'; + } + ajax.options.url += Drupal.ajax.WRAPPER_FORMAT + '=drupal_' + (element_settings.dialogType || 'ajax'); + + // Bind the ajaxSubmit function to the element event. + $(ajax.element).on(element_settings.event, function (event) { + if (!drupalSettings.ajaxTrustedUrl[ajax.url] && !Drupal.url.isLocal(ajax.url)) { + throw new Error(Drupal.t('The callback URL is not local and not trusted: !url', {'!url': ajax.url})); + } + return ajax.eventResponse(this, event); + }); + + // If necessary, enable keyboard submission so that Ajax behaviors + // can be triggered through keyboard input as well as e.g. a mousedown + // action. + if (element_settings.keypress) { + $(ajax.element).on('keypress', function (event) { + return ajax.keypressResponse(this, event); + }); + } + + // If necessary, prevent the browser default action of an additional event. + // For example, prevent the browser default action of a click, even if the + // Ajax behavior binds to mousedown. + if (element_settings.prevent) { + $(ajax.element).on(element_settings.prevent, false); + } + }; + + /** + * URL query attribute to indicate the wrapper used to render a request. + * + * The wrapper format determines how the HTML is wrapped, for example in a + * modal dialog. + * + * @const {string} + * + * @default + */ + Drupal.ajax.WRAPPER_FORMAT = '_wrapper_format'; + + /** + * Request parameter to indicate that a request is a Drupal Ajax request. + * + * @const {string} + * + * @default + */ + Drupal.Ajax.AJAX_REQUEST_PARAMETER = '_drupal_ajax'; + + /** + * Execute the ajax request. + * + * Allows developers to execute an Ajax request manually without specifying + * an event to respond to. + * + * @return {object} + * Returns the jQuery.Deferred object underlying the Ajax request. If + * pre-serialization fails, the Deferred will be returned in the rejected + * state. + */ + Drupal.Ajax.prototype.execute = function () { + // Do not perform another ajax command if one is already in progress. + if (this.ajaxing) { + return; + } + + try { + this.beforeSerialize(this.element, this.options); + // Return the jqXHR so that external code can hook into the Deferred API. + return $.ajax(this.options); + } + catch (e) { + // Unset the ajax.ajaxing flag here because it won't be unset during + // the complete response. + this.ajaxing = false; + window.alert('An error occurred while attempting to process ' + this.options.url + ': ' + e.message); + // For consistency, return a rejected Deferred (i.e., jqXHR's superclass) + // so that calling code can take appropriate action. + return $.Deferred().reject(); + } + }; + + /** + * Handle a key press. + * + * The Ajax object will, if instructed, bind to a key press response. This + * will test to see if the key press is valid to trigger this event and + * if it is, trigger it for us and prevent other keypresses from triggering. + * In this case we're handling RETURN and SPACEBAR keypresses (event codes 13 + * and 32. RETURN is often used to submit a form when in a textfield, and + * SPACE is often used to activate an element without submitting. + * + * @param {HTMLElement} element + * Element the event was triggered on. + * @param {jQuery.Event} event + * Triggered event. + */ + Drupal.Ajax.prototype.keypressResponse = function (element, event) { + // Create a synonym for this to reduce code confusion. + var ajax = this; + + // Detect enter key and space bar and allow the standard response for them, + // except for form elements of type 'text', 'tel', 'number' and 'textarea', + // where the spacebar activation causes inappropriate activation if + // #ajax['keypress'] is TRUE. On a text-type widget a space should always + // be a space. + if (event.which === 13 || (event.which === 32 && element.type !== 'text' && + element.type !== 'textarea' && element.type !== 'tel' && element.type !== 'number')) { + event.preventDefault(); + event.stopPropagation(); + $(ajax.element_settings.element).trigger(ajax.element_settings.event); + } + }; + + /** + * Handle an event that triggers an Ajax response. + * + * When an event that triggers an Ajax response happens, this method will + * perform the actual Ajax call. It is bound to the event using + * bind() in the constructor, and it uses the options specified on the + * Ajax object. + * + * @param {HTMLElement} element + * Element the event was triggered on. + * @param {jQuery.Event} event + * Triggered event. + */ + Drupal.Ajax.prototype.eventResponse = function (element, event) { + event.preventDefault(); + event.stopPropagation(); + + // Create a synonym for this to reduce code confusion. + var ajax = this; + + // Do not perform another Ajax command if one is already in progress. + if (ajax.ajaxing) { + return; + } + + try { + if (ajax.$form) { + // If setClick is set, we must set this to ensure that the button's + // value is passed. + if (ajax.setClick) { + // Mark the clicked button. 'form.clk' is a special variable for + // ajaxSubmit that tells the system which element got clicked to + // trigger the submit. Without it there would be no 'op' or + // equivalent. + element.form.clk = element; + } + + ajax.$form.ajaxSubmit(ajax.options); + } + else { + ajax.beforeSerialize(ajax.element, ajax.options); + $.ajax(ajax.options); + } + } + catch (e) { + // Unset the ajax.ajaxing flag here because it won't be unset during + // the complete response. + ajax.ajaxing = false; + window.alert('An error occurred while attempting to process ' + ajax.options.url + ': ' + e.message); + } + }; + + /** + * Handler for the form serialization. + * + * Runs before the beforeSend() handler (see below), and unlike that one, runs + * before field data is collected. + * + * @param {object} [element] + * Ajax object's `element_settings`. + * @param {object} options + * jQuery.ajax options. + */ + Drupal.Ajax.prototype.beforeSerialize = function (element, options) { + // Allow detaching behaviors to update field values before collecting them. + // This is only needed when field values are added to the POST data, so only + // when there is a form such that this.$form.ajaxSubmit() is used instead of + // $.ajax(). When there is no form and $.ajax() is used, beforeSerialize() + // isn't called, but don't rely on that: explicitly check this.$form. + if (this.$form) { + var settings = this.settings || drupalSettings; + Drupal.detachBehaviors(this.$form.get(0), settings, 'serialize'); + } + + // Inform Drupal that this is an AJAX request. + options.data[Drupal.Ajax.AJAX_REQUEST_PARAMETER] = 1; + + // Allow Drupal to return new JavaScript and CSS files to load without + // returning the ones already loaded. + // @see \Drupal\Core\Theme\AjaxBasePageNegotiator + // @see \Drupal\Core\Asset\LibraryDependencyResolverInterface::getMinimalRepresentativeSubset() + // @see system_js_settings_alter() + var pageState = drupalSettings.ajaxPageState; + options.data['ajax_page_state[theme]'] = pageState.theme; + options.data['ajax_page_state[theme_token]'] = pageState.theme_token; + options.data['ajax_page_state[libraries]'] = pageState.libraries; + }; + + /** + * Modify form values prior to form submission. + * + * @param {Array.<object>} form_values + * Processed form values. + * @param {jQuery} element + * The form node as a jQuery object. + * @param {object} options + * jQuery.ajax options. + */ + Drupal.Ajax.prototype.beforeSubmit = function (form_values, element, options) { + // This function is left empty to make it simple to override for modules + // that wish to add functionality here. + }; + + /** + * Prepare the Ajax request before it is sent. + * + * @param {XMLHttpRequest} xmlhttprequest + * Native Ajax object. + * @param {object} options + * jQuery.ajax options. + */ + Drupal.Ajax.prototype.beforeSend = function (xmlhttprequest, options) { + // For forms without file inputs, the jQuery Form plugin serializes the + // form values, and then calls jQuery's $.ajax() function, which invokes + // this handler. In this circumstance, options.extraData is never used. For + // forms with file inputs, the jQuery Form plugin uses the browser's normal + // form submission mechanism, but captures the response in a hidden IFRAME. + // In this circumstance, it calls this handler first, and then appends + // hidden fields to the form to submit the values in options.extraData. + // There is no simple way to know which submission mechanism will be used, + // so we add to extraData regardless, and allow it to be ignored in the + // former case. + if (this.$form) { + options.extraData = options.extraData || {}; + + // Let the server know when the IFRAME submission mechanism is used. The + // server can use this information to wrap the JSON response in a + // TEXTAREA, as per http://jquery.malsup.com/form/#file-upload. + options.extraData.ajax_iframe_upload = '1'; + + // The triggering element is about to be disabled (see below), but if it + // contains a value (e.g., a checkbox, textfield, select, etc.), ensure + // that value is included in the submission. As per above, submissions + // that use $.ajax() are already serialized prior to the element being + // disabled, so this is only needed for IFRAME submissions. + var v = $.fieldValue(this.element); + if (v !== null) { + options.extraData[this.element.name] = v; + } + } + + // Disable the element that received the change to prevent user interface + // interaction while the Ajax request is in progress. ajax.ajaxing prevents + // the element from triggering a new request, but does not prevent the user + // from changing its value. + $(this.element).prop('disabled', true); + + if (!this.progress || !this.progress.type) { + return; + } + + // Insert progress indicator. + var progressIndicatorMethod = 'setProgressIndicator' + this.progress.type.slice(0, 1).toUpperCase() + this.progress.type.slice(1).toLowerCase(); + if (progressIndicatorMethod in this && typeof this[progressIndicatorMethod] === 'function') { + this[progressIndicatorMethod].call(this); + } + }; + + /** + * Sets the progress bar progress indicator. + */ + Drupal.Ajax.prototype.setProgressIndicatorBar = function () { + var progressBar = new Drupal.ProgressBar('ajax-progress-' + this.element.id, $.noop, this.progress.method, $.noop); + if (this.progress.message) { + progressBar.setProgress(-1, this.progress.message); + } + if (this.progress.url) { + progressBar.startMonitoring(this.progress.url, this.progress.interval || 1500); + } + this.progress.element = $(progressBar.element).addClass('ajax-progress ajax-progress-bar'); + this.progress.object = progressBar; + $(this.element).after(this.progress.element); + }; + + /** + * Sets the throbber progress indicator. + */ + Drupal.Ajax.prototype.setProgressIndicatorThrobber = function () { + this.progress.element = $('<div class="ajax-progress ajax-progress-throbber"><div class="throbber"> </div></div>'); + if (this.progress.message) { + this.progress.element.find('.throbber').after('<div class="message">' + this.progress.message + '</div>'); + } + $(this.element).after(this.progress.element); + }; + + /** + * Sets the fullscreen progress indicator. + */ + Drupal.Ajax.prototype.setProgressIndicatorFullscreen = function () { + this.progress.element = $('<div class="ajax-progress ajax-progress-fullscreen"> </div>'); + $('body').after(this.progress.element); + }; + + /** + * Handler for the form redirection completion. + * + * @param {Array.<Drupal.AjaxCommands~commandDefinition>} response + * Drupal Ajax response. + * @param {number} status + * XMLHttpRequest status. + */ + Drupal.Ajax.prototype.success = function (response, status) { + // Remove the progress element. + if (this.progress.element) { + $(this.progress.element).remove(); + } + if (this.progress.object) { + this.progress.object.stopMonitoring(); + } + $(this.element).prop('disabled', false); + + // Save element's ancestors tree so if the element is removed from the dom + // we can try to refocus one of its parents. Using addBack reverse the + // result array, meaning that index 0 is the highest parent in the hierarchy + // in this situation it is usually a <form> element. + var elementParents = $(this.element).parents('[data-drupal-selector]').addBack().toArray(); + + // Track if any command is altering the focus so we can avoid changing the + // focus set by the Ajax command. + var focusChanged = false; + 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); + if (response[i].command === 'invoke' && response[i].method === 'focus') { + focusChanged = true; + } + } + } + + // If the focus hasn't be changed by the ajax commands, try to refocus the + // triggering element or one of its parents if that element does not exist + // anymore. + if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) { + var target = false; + + for (var n = elementParents.length - 1; !target && n > 0; n--) { + target = document.querySelector('[data-drupal-selector="' + elementParents[n].getAttribute('data-drupal-selector') + '"]'); + } + + if (target) { + $(target).trigger('focus'); + } + } + + // Reattach behaviors, if they were detached in beforeSerialize(). The + // attachBehaviors() called on the new content from processing the response + // commands is not sufficient, because behaviors from the entire form need + // to be reattached. + if (this.$form) { + var settings = this.settings || drupalSettings; + Drupal.attachBehaviors(this.$form.get(0), settings); + } + + // Remove any response-specific settings so they don't get used on the next + // call by mistake. + this.settings = null; + }; + + /** + * Build an effect object to apply an effect when adding new HTML. + * + * @param {object} response + * Drupal Ajax response. + * @param {string} [response.effect] + * Override the default value of {@link Drupal.Ajax#element_settings}. + * @param {string|number} [response.speed] + * Override the default value of {@link Drupal.Ajax#element_settings}. + * + * @return {object} + * Returns an object with `showEffect`, `hideEffect` and `showSpeed` + * properties. + */ + Drupal.Ajax.prototype.getEffect = function (response) { + var type = response.effect || this.effect; + var speed = response.speed || this.speed; + + var effect = {}; + if (type === 'none') { + effect.showEffect = 'show'; + effect.hideEffect = 'hide'; + effect.showSpeed = ''; + } + else if (type === 'fade') { + effect.showEffect = 'fadeIn'; + effect.hideEffect = 'fadeOut'; + effect.showSpeed = speed; + } + else { + effect.showEffect = type + 'Toggle'; + effect.hideEffect = type + 'Toggle'; + effect.showSpeed = speed; + } + + return effect; + }; + + /** + * Handler for the form redirection error. + * + * @param {object} xmlhttprequest + * Native XMLHttpRequest object. + * @param {string} uri + * Ajax Request URI. + * @param {string} [customMessage] + * Extra message to print with the Ajax error. + */ + Drupal.Ajax.prototype.error = function (xmlhttprequest, uri, customMessage) { + // Remove the progress element. + if (this.progress.element) { + $(this.progress.element).remove(); + } + if (this.progress.object) { + this.progress.object.stopMonitoring(); + } + // Undo hide. + $(this.wrapper).show(); + // Re-enable the element. + $(this.element).prop('disabled', false); + // Reattach behaviors, if they were detached in beforeSerialize(). + if (this.$form) { + var settings = this.settings || drupalSettings; + Drupal.attachBehaviors(this.$form.get(0), settings); + } + throw new Drupal.AjaxError(xmlhttprequest, uri, customMessage); + }; + + /** + * @typedef {object} Drupal.AjaxCommands~commandDefinition + * + * @prop {string} command + * @prop {string} [method] + * @prop {string} [selector] + * @prop {string} [data] + * @prop {object} [settings] + * @prop {bool} [asterisk] + * @prop {string} [text] + * @prop {string} [title] + * @prop {string} [url] + * @prop {object} [argument] + * @prop {string} [name] + * @prop {string} [value] + * @prop {string} [old] + * @prop {string} [new] + * @prop {bool} [merge] + * @prop {Array} [args] + * + * @see Drupal.AjaxCommands + */ + + /** + * Provide a series of commands that the client will perform. + * + * @constructor + */ + Drupal.AjaxCommands = function () {}; + Drupal.AjaxCommands.prototype = { + + /** + * Command to insert new content into the DOM. + * + * @param {Drupal.Ajax} ajax + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.data + * The data to use with the jQuery method. + * @param {string} [response.method] + * The jQuery DOM manipulation method to be used. + * @param {string} [response.selector] + * A optional jQuery selector string. + * @param {object} [response.settings] + * An optional array of settings that will be used. + * @param {number} [status] + * The XMLHttpRequest status. + */ + insert: function (ajax, response, status) { + // Get information from the response. If it is not there, default to + // our presets. + var $wrapper = response.selector ? $(response.selector) : $(ajax.wrapper); + var method = response.method || ajax.method; + var effect = ajax.getEffect(response); + var settings; + + // We don't know what response.data contains: it might be a string of text + // without HTML, so don't rely on jQuery correctly interpreting + // $(response.data) as new HTML rather than a CSS selector. Also, if + // response.data contains top-level text nodes, they get lost with either + // $(response.data) or $('<div></div>').replaceWith(response.data). + var $new_content_wrapped = $('<div></div>').html(response.data); + var $new_content = $new_content_wrapped.contents(); + + // For legacy reasons, the effects processing code assumes that + // $new_content consists of a single top-level element. Also, it has not + // been sufficiently tested whether attachBehaviors() can be successfully + // called with a context object that includes top-level text nodes. + // However, to give developers full control of the HTML appearing in the + // page, and to enable Ajax content to be inserted in places where <div> + // elements are not allowed (e.g., within <table>, <tr>, and <span> + // parents), we check if the new content satisfies the requirement + // of a single top-level element, and only use the container <div> created + // above when it doesn't. For more information, please see + // https://www.drupal.org/node/736066. + if ($new_content.length !== 1 || $new_content.get(0).nodeType !== 1) { + $new_content = $new_content_wrapped; + } + + // If removing content from the wrapper, detach behaviors first. + switch (method) { + case 'html': + case 'replaceWith': + case 'replaceAll': + case 'empty': + case 'remove': + settings = response.settings || ajax.settings || drupalSettings; + Drupal.detachBehaviors($wrapper.get(0), settings); + } + + // Add the new content to the page. + $wrapper[method]($new_content); + + // Immediately hide the new content if we're using any effects. + if (effect.showEffect !== 'show') { + $new_content.hide(); + } + + // Determine which effect to use and what content will receive the + // effect, then show the new content. + if ($new_content.find('.ajax-new-content').length > 0) { + $new_content.find('.ajax-new-content').hide(); + $new_content.show(); + $new_content.find('.ajax-new-content')[effect.showEffect](effect.showSpeed); + } + else if (effect.showEffect !== 'show') { + $new_content[effect.showEffect](effect.showSpeed); + } + + // Attach all JavaScript behaviors to the new content, if it was + // successfully added to the page, this if statement allows + // `#ajax['wrapper']` to be optional. + if ($new_content.parents('html').length > 0) { + // Apply any settings from the returned JSON if available. + settings = response.settings || ajax.settings || drupalSettings; + Drupal.attachBehaviors($new_content.get(0), settings); + } + }, + + /** + * Command to remove a chunk from the page. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.selector + * A jQuery selector string. + * @param {object} [response.settings] + * An optional array of settings that will be used. + * @param {number} [status] + * The XMLHttpRequest status. + */ + remove: function (ajax, response, status) { + var settings = response.settings || ajax.settings || drupalSettings; + $(response.selector).each(function () { + Drupal.detachBehaviors(this, settings); + }) + .remove(); + }, + + /** + * Command to mark a chunk changed. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The JSON response object from the Ajax request. + * @param {string} response.selector + * A jQuery selector string. + * @param {bool} [response.asterisk] + * An optional CSS selector. If specified, an asterisk will be + * appended to the HTML inside the provided selector. + * @param {number} [status] + * The request status. + */ + changed: function (ajax, response, status) { + var $element = $(response.selector); + if (!$element.hasClass('ajax-changed')) { + $element.addClass('ajax-changed'); + if (response.asterisk) { + $element.find(response.asterisk).append(' <abbr class="ajax-changed" title="' + Drupal.t('Changed') + '">*</abbr> '); + } + } + }, + + /** + * Command to provide an alert. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The JSON response from the Ajax request. + * @param {string} response.text + * The text that will be displayed in an alert dialog. + * @param {number} [status] + * The XMLHttpRequest status. + */ + alert: function (ajax, response, status) { + window.alert(response.text, response.title); + }, + + /** + * Command to set the window.location, redirecting the browser. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.url + * The URL to redirect to. + * @param {number} [status] + * The XMLHttpRequest status. + */ + redirect: function (ajax, response, status) { + window.location = response.url; + }, + + /** + * Command to provide the jQuery css() function. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.selector + * A jQuery selector string. + * @param {object} response.argument + * An array of key/value pairs to set in the CSS for the selector. + * @param {number} [status] + * The XMLHttpRequest status. + */ + css: function (ajax, response, status) { + $(response.selector).css(response.argument); + }, + + /** + * Command to set the settings used for other commands in this response. + * + * This method will also remove expired `drupalSettings.ajax` settings. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {bool} response.merge + * Determines whether the additional settings should be merged to the + * global settings. + * @param {object} response.settings + * Contains additional settings to add to the global settings. + * @param {number} [status] + * The XMLHttpRequest status. + */ + settings: function (ajax, response, status) { + var ajaxSettings = drupalSettings.ajax; + + // Clean up drupalSettings.ajax. + if (ajaxSettings) { + Drupal.ajax.expired().forEach(function (instance) { + // If the Ajax object has been created through drupalSettings.ajax + // it will have a selector. When there is no selector the object + // has been initialized with a special class name picked up by the + // Ajax behavior. + + if (instance.selector) { + var selector = instance.selector.replace('#', ''); + if (selector in ajaxSettings) { + delete ajaxSettings[selector]; + } + } + }); + } + + if (response.merge) { + $.extend(true, drupalSettings, response.settings); + } + else { + ajax.settings = response.settings; + } + }, + + /** + * Command to attach data using jQuery's data API. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.name + * The name or key (in the key value pair) of the data attached to this + * selector. + * @param {string} response.selector + * A jQuery selector string. + * @param {string|object} response.value + * The value of to be attached. + * @param {number} [status] + * The XMLHttpRequest status. + */ + data: function (ajax, response, status) { + $(response.selector).data(response.name, response.value); + }, + + /** + * Command to apply a jQuery method. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {Array} response.args + * An array of arguments to the jQuery method, if any. + * @param {string} response.method + * The jQuery method to invoke. + * @param {string} response.selector + * A jQuery selector string. + * @param {number} [status] + * The XMLHttpRequest status. + */ + invoke: function (ajax, response, status) { + var $element = $(response.selector); + $element[response.method].apply($element, response.args); + }, + + /** + * Command to restripe a table. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.selector + * A jQuery selector string. + * @param {number} [status] + * The XMLHttpRequest status. + */ + restripe: function (ajax, response, status) { + // :even and :odd are reversed because jQuery counts from 0 and + // we count from 1, so we're out of sync. + // Match immediate children of the parent element to allow nesting. + $(response.selector).find('> tbody > tr:visible, > tr:visible') + .removeClass('odd even') + .filter(':even').addClass('odd').end() + .filter(':odd').addClass('even'); + }, + + /** + * Command to update a form's build ID. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.old + * The old form build ID. + * @param {string} response.new + * The new form build ID. + * @param {number} [status] + * The XMLHttpRequest status. + */ + update_build_id: function (ajax, response, status) { + $('input[name="form_build_id"][value="' + response.old + '"]').val(response.new); + }, + + /** + * Command to add css. + * + * Uses the proprietary addImport method if available as browsers which + * support that method ignore @import statements in dynamically added + * stylesheets. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.data + * A string that contains the styles to be added. + * @param {number} [status] + * The XMLHttpRequest status. + */ + add_css: function (ajax, response, status) { + // Add the styles in the normal way. + $('head').prepend(response.data); + // Add imports in the styles using the addImport method if available. + var match; + var importMatch = /^@import url\("(.*)"\);$/igm; + if (document.styleSheets[0].addImport && importMatch.test(response.data)) { + importMatch.lastIndex = 0; + do { + match = importMatch.exec(response.data); + document.styleSheets[0].addImport(match[1]); + } while (match); + } + } + }; + +})(jQuery, window, Drupal, drupalSettings); diff --git a/core/misc/ajax.js b/core/misc/ajax.js index fefe9f3031ef..89b47988bcc0 100644 --- a/core/misc/ajax.js +++ b/core/misc/ajax.js @@ -1,35 +1,17 @@ /** - * @file - * Provides Ajax page updating via jQuery $.ajax. - * - * Ajax is a method of making a request via JavaScript while viewing an HTML - * page. The request returns an array of commands encoded in JSON, which is - * then executed to make any changes that are necessary to the page. - * - * Drupal uses this file to enhance form elements with `#ajax['url']` and - * `#ajax['wrapper']` properties. If set, this file will automatically be - * included to provide Ajax capabilities. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/ajax.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, window, Drupal, drupalSettings) { 'use strict'; - /** - * Attaches the Ajax behavior to each Ajax form element. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Initialize all {@link Drupal.Ajax} objects declared in - * `drupalSettings.ajax` or initialize {@link Drupal.Ajax} objects from - * DOM elements having the `use-ajax-submit` or `use-ajax` css class. - * @prop {Drupal~behaviorDetach} detach - * During `unload` remove all {@link Drupal.Ajax} objects related to - * the removed content. - */ Drupal.behaviors.AJAX = { - attach: function (context, settings) { + attach: function attach(context, settings) { function loadAjaxBehavior(base) { var element_settings = settings.ajax[base]; @@ -43,21 +25,17 @@ }); } - // Load all Ajax behaviors specified in the settings. for (var base in settings.ajax) { if (settings.ajax.hasOwnProperty(base)) { loadAjaxBehavior(base); } } - // Bind Ajax behaviors to all items showing the class. $('.use-ajax').once('ajax').each(function () { var element_settings = {}; - // Clicked links look better with the throbber than the progress bar. - element_settings.progress = {type: 'throbber'}; - // For anchor tags, these will go to the target of the anchor rather - // than the usual location. + element_settings.progress = { type: 'throbber' }; + var href = $(this).attr('href'); if (href) { element_settings.url = href; @@ -70,21 +48,16 @@ Drupal.ajax(element_settings); }); - // This class means to submit the form to the action using Ajax. $('.use-ajax-submit').once('ajax').each(function () { var element_settings = {}; - // Ajax submits specified in this manner automatically submit to the - // normal form action. element_settings.url = $(this.form).attr('action'); - // Form submit button clicks need to tell the form what was clicked so - // it gets passed in the POST request. + element_settings.setClick = true; - // Form buttons use the 'click' event rather than mousedown. + element_settings.event = 'click'; - // Clicked form buttons look better with the throbber than the progress - // bar. - element_settings.progress = {type: 'throbber'}; + + element_settings.progress = { type: 'throbber' }; element_settings.base = $(this).attr('id'); element_settings.element = this; @@ -92,31 +65,15 @@ }); }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload') { Drupal.ajax.expired().forEach(function (instance) { - // Set this to null and allow garbage collection to reclaim - // the memory. Drupal.ajax.instances[instance.instanceIndex] = null; }); } } }; - /** - * Extends Error to provide handling for Errors in Ajax. - * - * @constructor - * - * @augments Error - * - * @param {XMLHttpRequest} xmlhttp - * XMLHttpRequest object used for the failed request. - * @param {string} uri - * The URI where the error occurred. - * @param {string} customMessage - * The custom message. - */ Drupal.AjaxError = function (xmlhttp, uri, customMessage) { var statusCode; @@ -125,124 +82,49 @@ var responseText; var readyStateText; if (xmlhttp.status) { - statusCode = '\n' + Drupal.t('An AJAX HTTP error occurred.') + '\n' + Drupal.t('HTTP Result Code: !status', {'!status': xmlhttp.status}); - } - else { + statusCode = '\n' + Drupal.t('An AJAX HTTP error occurred.') + '\n' + Drupal.t('HTTP Result Code: !status', { '!status': xmlhttp.status }); + } else { statusCode = '\n' + Drupal.t('An AJAX HTTP request terminated abnormally.'); } statusCode += '\n' + Drupal.t('Debugging information follows.'); - pathText = '\n' + Drupal.t('Path: !uri', {'!uri': uri}); + pathText = '\n' + Drupal.t('Path: !uri', { '!uri': uri }); statusText = ''; - // In some cases, when statusCode === 0, xmlhttp.statusText may not be - // defined. Unfortunately, testing for it with typeof, etc, doesn't seem to - // catch that and the test causes an exception. So we need to catch the - // exception here. + try { - statusText = '\n' + Drupal.t('StatusText: !statusText', {'!statusText': $.trim(xmlhttp.statusText)}); - } - catch (e) { - // Empty. - } + statusText = '\n' + Drupal.t('StatusText: !statusText', { '!statusText': $.trim(xmlhttp.statusText) }); + } catch (e) {} responseText = ''; - // Again, we don't have a way to know for sure whether accessing - // xmlhttp.responseText is going to throw an exception. So we'll catch it. + try { - responseText = '\n' + Drupal.t('ResponseText: !responseText', {'!responseText': $.trim(xmlhttp.responseText)}); - } - catch (e) { - // Empty. - } + responseText = '\n' + Drupal.t('ResponseText: !responseText', { '!responseText': $.trim(xmlhttp.responseText) }); + } catch (e) {} - // Make the responseText more readable by stripping HTML tags and newlines. responseText = responseText.replace(/<("[^"]*"|'[^']*'|[^'">])*>/gi, ''); responseText = responseText.replace(/[\n]+\s+/g, '\n'); - // We don't need readyState except for status == 0. - readyStateText = xmlhttp.status === 0 ? ('\n' + Drupal.t('ReadyState: !readyState', {'!readyState': xmlhttp.readyState})) : ''; + readyStateText = xmlhttp.status === 0 ? '\n' + Drupal.t('ReadyState: !readyState', { '!readyState': xmlhttp.readyState }) : ''; - customMessage = customMessage ? ('\n' + Drupal.t('CustomMessage: !customMessage', {'!customMessage': customMessage})) : ''; + customMessage = customMessage ? '\n' + Drupal.t('CustomMessage: !customMessage', { '!customMessage': customMessage }) : ''; - /** - * Formatted and translated error message. - * - * @type {string} - */ this.message = statusCode + pathText + statusText + customMessage + responseText + readyStateText; - /** - * Used by some browsers to display a more accurate stack trace. - * - * @type {string} - */ this.name = 'AjaxError'; }; Drupal.AjaxError.prototype = new Error(); Drupal.AjaxError.prototype.constructor = Drupal.AjaxError; - /** - * Provides Ajax page updating via jQuery $.ajax. - * - * This function is designed to improve developer experience by wrapping the - * initialization of {@link Drupal.Ajax} objects and storing all created - * objects in the {@link Drupal.ajax.instances} array. - * - * @example - * Drupal.behaviors.myCustomAJAXStuff = { - * attach: function (context, settings) { - * - * var ajaxSettings = { - * url: 'my/url/path', - * // If the old version of Drupal.ajax() needs to be used those - * // properties can be added - * base: 'myBase', - * element: $(context).find('.someElement') - * }; - * - * var myAjaxObject = Drupal.ajax(ajaxSettings); - * - * // Declare a new Ajax command specifically for this Ajax object. - * myAjaxObject.commands.insert = function (ajax, response, status) { - * $('#my-wrapper').append(response.data); - * alert('New content was appended to #my-wrapper'); - * }; - * - * // This command will remove this Ajax object from the page. - * myAjaxObject.commands.destroyObject = function (ajax, response, status) { - * Drupal.ajax.instances[this.instanceIndex] = null; - * }; - * - * // Programmatically trigger the Ajax request. - * myAjaxObject.execute(); - * } - * }; - * - * @param {object} settings - * The settings object passed to {@link Drupal.Ajax} constructor. - * @param {string} [settings.base] - * Base is passed to {@link Drupal.Ajax} constructor as the 'base' - * parameter. - * @param {HTMLElement} [settings.element] - * Element parameter of {@link Drupal.Ajax} constructor, element on which - * event listeners will be bound. - * - * @return {Drupal.Ajax} - * The created Ajax object. - * - * @see Drupal.AjaxCommands - */ Drupal.ajax = function (settings) { if (arguments.length !== 1) { throw new Error('Drupal.ajax() function must be called with one configuration object only'); } - // Map those config keys to variables for the old Drupal.ajax function. + var base = settings.base || false; var element = settings.element || false; delete settings.base; delete settings.element; - // By default do not display progress for ajax calls without an element. if (!settings.progress && !element) { settings.progress = false; } @@ -254,88 +136,14 @@ return ajax; }; - /** - * Contains all created Ajax objects. - * - * @type {Array.<Drupal.Ajax|null>} - */ Drupal.ajax.instances = []; - /** - * List all objects where the associated element is not in the DOM - * - * This method ignores {@link Drupal.Ajax} objects not bound to DOM elements - * when created with {@link Drupal.ajax}. - * - * @return {Array.<Drupal.Ajax>} - * The list of expired {@link Drupal.Ajax} objects. - */ Drupal.ajax.expired = function () { return Drupal.ajax.instances.filter(function (instance) { return instance && instance.element !== false && !document.body.contains(instance.element); }); }; - /** - * Settings for an Ajax object. - * - * @typedef {object} Drupal.Ajax~element_settings - * - * @prop {string} url - * Target of the Ajax request. - * @prop {?string} [event] - * Event bound to settings.element which will trigger the Ajax request. - * @prop {bool} [keypress=true] - * Triggers a request on keypress events. - * @prop {?string} selector - * jQuery selector targeting the element to bind events to or used with - * {@link Drupal.AjaxCommands}. - * @prop {string} [effect='none'] - * Name of the jQuery method to use for displaying new Ajax content. - * @prop {string|number} [speed='none'] - * Speed with which to apply the effect. - * @prop {string} [method] - * Name of the jQuery method used to insert new content in the targeted - * element. - * @prop {object} [progress] - * Settings for the display of a user-friendly loader. - * @prop {string} [progress.type='throbber'] - * Type of progress element, core provides `'bar'`, `'throbber'` and - * `'fullscreen'`. - * @prop {string} [progress.message=Drupal.t('Please wait...')] - * Custom message to be used with the bar indicator. - * @prop {object} [submit] - * Extra data to be sent with the Ajax request. - * @prop {bool} [submit.js=true] - * Allows the PHP side to know this comes from an Ajax request. - * @prop {object} [dialog] - * Options for {@link Drupal.dialog}. - * @prop {string} [dialogType] - * One of `'modal'` or `'dialog'`. - * @prop {string} [prevent] - * List of events on which to stop default action and stop propagation. - */ - - /** - * Ajax constructor. - * - * The Ajax request returns an array of commands encoded in JSON, which is - * then executed to make any changes that are necessary to the page. - * - * Drupal uses this file to enhance form elements with `#ajax['url']` and - * `#ajax['wrapper']` properties. If set, this file will automatically be - * included to provide Ajax capabilities. - * - * @constructor - * - * @param {string} [base] - * Base parameter of {@link Drupal.Ajax} constructor - * @param {HTMLElement} [element] - * Element parameter of {@link Drupal.Ajax} constructor, element on which - * event listeners will be bound. - * @param {Drupal.Ajax~element_settings} element_settings - * Settings for this Ajax object. - */ Drupal.Ajax = function (base, element, element_settings) { var defaults = { event: element ? 'mousedown' : null, @@ -355,146 +163,60 @@ $.extend(this, defaults, element_settings); - /** - * @type {Drupal.AjaxCommands} - */ this.commands = new Drupal.AjaxCommands(); - /** - * @type {bool|number} - */ this.instanceIndex = false; - // @todo Remove this after refactoring the PHP code to: - // - Call this 'selector'. - // - Include the '#' for ID-based selectors. - // - Support non-ID-based selectors. if (this.wrapper) { - - /** - * @type {string} - */ this.wrapper = '#' + this.wrapper; } - /** - * @type {HTMLElement} - */ this.element = element; - /** - * @type {Drupal.Ajax~element_settings} - */ this.element_settings = element_settings; - // If there isn't a form, jQuery.ajax() will be used instead, allowing us to - // bind Ajax to links as well. if (this.element && this.element.form) { - - /** - * @type {jQuery} - */ this.$form = $(this.element.form); } - // If no Ajax callback URL was given, use the link href or form action. if (!this.url) { var $element = $(this.element); if ($element.is('a')) { this.url = $element.attr('href'); - } - else if (this.element && element.form) { + } else if (this.element && element.form) { this.url = this.$form.attr('action'); } } - // Replacing 'nojs' with 'ajax' in the URL allows for an easy method to let - // the server detect when it needs to degrade gracefully. - // There are four scenarios to check for: - // 1. /nojs/ - // 2. /nojs$ - The end of a URL string. - // 3. /nojs? - Followed by a query (e.g. path/nojs?destination=foobar). - // 4. /nojs# - Followed by a fragment (e.g.: path/nojs#myfragment). var originalUrl = this.url; - /** - * Processed Ajax URL. - * - * @type {string} - */ this.url = this.url.replace(/\/nojs(\/|$|\?|#)/g, '/ajax$1'); - // If the 'nojs' version of the URL is trusted, also trust the 'ajax' - // version. + if (drupalSettings.ajaxTrustedUrl[originalUrl]) { drupalSettings.ajaxTrustedUrl[this.url] = true; } - // Set the options for the ajaxSubmit function. - // The 'this' variable will not persist inside of the options object. var ajax = this; - /** - * Options for the jQuery.ajax function. - * - * @name Drupal.Ajax#options - * - * @type {object} - * - * @prop {string} url - * Ajax URL to be called. - * @prop {object} data - * Ajax payload. - * @prop {function} beforeSerialize - * Implement jQuery beforeSerialize function to call - * {@link Drupal.Ajax#beforeSerialize}. - * @prop {function} beforeSubmit - * Implement jQuery beforeSubmit function to call - * {@link Drupal.Ajax#beforeSubmit}. - * @prop {function} beforeSend - * Implement jQuery beforeSend function to call - * {@link Drupal.Ajax#beforeSend}. - * @prop {function} success - * Implement jQuery success function to call - * {@link Drupal.Ajax#success}. - * @prop {function} complete - * Implement jQuery success function to clean up ajax state and trigger an - * error if needed. - * @prop {string} dataType='json' - * Type of the response expected. - * @prop {string} type='POST' - * HTTP method to use for the Ajax request. - */ ajax.options = { url: ajax.url, data: ajax.submit, - beforeSerialize: function (element_settings, options) { + beforeSerialize: function beforeSerialize(element_settings, options) { return ajax.beforeSerialize(element_settings, options); }, - beforeSubmit: function (form_values, element_settings, options) { + beforeSubmit: function beforeSubmit(form_values, element_settings, options) { ajax.ajaxing = true; return ajax.beforeSubmit(form_values, element_settings, options); }, - beforeSend: function (xmlhttprequest, options) { + beforeSend: function beforeSend(xmlhttprequest, options) { ajax.ajaxing = true; return ajax.beforeSend(xmlhttprequest, options); }, - success: function (response, status, xmlhttprequest) { - // Sanity check for browser support (object expected). - // When using iFrame uploads, responses must be returned as a string. + success: function success(response, status, xmlhttprequest) { if (typeof response === 'string') { response = $.parseJSON(response); } - // Prior to invoking the response's commands, verify that they can be - // trusted by checking for a response header. See - // \Drupal\Core\EventSubscriber\AjaxResponseSubscriber for details. - // - Empty responses are harmless so can bypass verification. This - // avoids an alert message for server-generated no-op responses that - // skip Ajax rendering. - // - Ajax objects with trusted URLs (e.g., ones defined server-side via - // #ajax) can bypass header verification. This is especially useful - // for Ajax with multipart forms. Because IFRAME transport is used, - // the response headers cannot be accessed for verification. if (response !== null && !drupalSettings.ajaxTrustedUrl[ajax.url]) { if (xmlhttprequest.getResponseHeader('X-Drupal-Ajax-Token') !== '1') { var customMessage = Drupal.t('The response failed verification so will not be processed.'); @@ -504,7 +226,7 @@ return ajax.success(response, status); }, - complete: function (xmlhttprequest, status) { + complete: function complete(xmlhttprequest, status) { ajax.ajaxing = false; if (status === 'error' || status === 'parsererror') { return ajax.error(xmlhttprequest, ajax.url); @@ -518,288 +240,129 @@ ajax.options.data.dialogOptions = element_settings.dialog; } - // Ensure that we have a valid URL by adding ? when no query parameter is - // yet available, otherwise append using &. if (ajax.options.url.indexOf('?') === -1) { ajax.options.url += '?'; - } - else { + } else { ajax.options.url += '&'; } ajax.options.url += Drupal.ajax.WRAPPER_FORMAT + '=drupal_' + (element_settings.dialogType || 'ajax'); - // Bind the ajaxSubmit function to the element event. $(ajax.element).on(element_settings.event, function (event) { if (!drupalSettings.ajaxTrustedUrl[ajax.url] && !Drupal.url.isLocal(ajax.url)) { - throw new Error(Drupal.t('The callback URL is not local and not trusted: !url', {'!url': ajax.url})); + throw new Error(Drupal.t('The callback URL is not local and not trusted: !url', { '!url': ajax.url })); } return ajax.eventResponse(this, event); }); - // If necessary, enable keyboard submission so that Ajax behaviors - // can be triggered through keyboard input as well as e.g. a mousedown - // action. if (element_settings.keypress) { $(ajax.element).on('keypress', function (event) { return ajax.keypressResponse(this, event); }); } - // If necessary, prevent the browser default action of an additional event. - // For example, prevent the browser default action of a click, even if the - // Ajax behavior binds to mousedown. if (element_settings.prevent) { $(ajax.element).on(element_settings.prevent, false); } }; - /** - * URL query attribute to indicate the wrapper used to render a request. - * - * The wrapper format determines how the HTML is wrapped, for example in a - * modal dialog. - * - * @const {string} - * - * @default - */ Drupal.ajax.WRAPPER_FORMAT = '_wrapper_format'; - /** - * Request parameter to indicate that a request is a Drupal Ajax request. - * - * @const {string} - * - * @default - */ Drupal.Ajax.AJAX_REQUEST_PARAMETER = '_drupal_ajax'; - /** - * Execute the ajax request. - * - * Allows developers to execute an Ajax request manually without specifying - * an event to respond to. - * - * @return {object} - * Returns the jQuery.Deferred object underlying the Ajax request. If - * pre-serialization fails, the Deferred will be returned in the rejected - * state. - */ Drupal.Ajax.prototype.execute = function () { - // Do not perform another ajax command if one is already in progress. if (this.ajaxing) { return; } try { this.beforeSerialize(this.element, this.options); - // Return the jqXHR so that external code can hook into the Deferred API. + return $.ajax(this.options); - } - catch (e) { - // Unset the ajax.ajaxing flag here because it won't be unset during - // the complete response. + } catch (e) { this.ajaxing = false; window.alert('An error occurred while attempting to process ' + this.options.url + ': ' + e.message); - // For consistency, return a rejected Deferred (i.e., jqXHR's superclass) - // so that calling code can take appropriate action. + return $.Deferred().reject(); } }; - /** - * Handle a key press. - * - * The Ajax object will, if instructed, bind to a key press response. This - * will test to see if the key press is valid to trigger this event and - * if it is, trigger it for us and prevent other keypresses from triggering. - * In this case we're handling RETURN and SPACEBAR keypresses (event codes 13 - * and 32. RETURN is often used to submit a form when in a textfield, and - * SPACE is often used to activate an element without submitting. - * - * @param {HTMLElement} element - * Element the event was triggered on. - * @param {jQuery.Event} event - * Triggered event. - */ Drupal.Ajax.prototype.keypressResponse = function (element, event) { - // Create a synonym for this to reduce code confusion. var ajax = this; - // Detect enter key and space bar and allow the standard response for them, - // except for form elements of type 'text', 'tel', 'number' and 'textarea', - // where the spacebar activation causes inappropriate activation if - // #ajax['keypress'] is TRUE. On a text-type widget a space should always - // be a space. - if (event.which === 13 || (event.which === 32 && element.type !== 'text' && - element.type !== 'textarea' && element.type !== 'tel' && element.type !== 'number')) { + if (event.which === 13 || event.which === 32 && element.type !== 'text' && element.type !== 'textarea' && element.type !== 'tel' && element.type !== 'number') { event.preventDefault(); event.stopPropagation(); $(ajax.element_settings.element).trigger(ajax.element_settings.event); } }; - /** - * Handle an event that triggers an Ajax response. - * - * When an event that triggers an Ajax response happens, this method will - * perform the actual Ajax call. It is bound to the event using - * bind() in the constructor, and it uses the options specified on the - * Ajax object. - * - * @param {HTMLElement} element - * Element the event was triggered on. - * @param {jQuery.Event} event - * Triggered event. - */ Drupal.Ajax.prototype.eventResponse = function (element, event) { event.preventDefault(); event.stopPropagation(); - // Create a synonym for this to reduce code confusion. var ajax = this; - // Do not perform another Ajax command if one is already in progress. if (ajax.ajaxing) { return; } try { if (ajax.$form) { - // If setClick is set, we must set this to ensure that the button's - // value is passed. if (ajax.setClick) { - // Mark the clicked button. 'form.clk' is a special variable for - // ajaxSubmit that tells the system which element got clicked to - // trigger the submit. Without it there would be no 'op' or - // equivalent. element.form.clk = element; } ajax.$form.ajaxSubmit(ajax.options); - } - else { + } else { ajax.beforeSerialize(ajax.element, ajax.options); $.ajax(ajax.options); } - } - catch (e) { - // Unset the ajax.ajaxing flag here because it won't be unset during - // the complete response. + } catch (e) { ajax.ajaxing = false; window.alert('An error occurred while attempting to process ' + ajax.options.url + ': ' + e.message); } }; - /** - * Handler for the form serialization. - * - * Runs before the beforeSend() handler (see below), and unlike that one, runs - * before field data is collected. - * - * @param {object} [element] - * Ajax object's `element_settings`. - * @param {object} options - * jQuery.ajax options. - */ Drupal.Ajax.prototype.beforeSerialize = function (element, options) { - // Allow detaching behaviors to update field values before collecting them. - // This is only needed when field values are added to the POST data, so only - // when there is a form such that this.$form.ajaxSubmit() is used instead of - // $.ajax(). When there is no form and $.ajax() is used, beforeSerialize() - // isn't called, but don't rely on that: explicitly check this.$form. if (this.$form) { var settings = this.settings || drupalSettings; Drupal.detachBehaviors(this.$form.get(0), settings, 'serialize'); } - // Inform Drupal that this is an AJAX request. options.data[Drupal.Ajax.AJAX_REQUEST_PARAMETER] = 1; - // Allow Drupal to return new JavaScript and CSS files to load without - // returning the ones already loaded. - // @see \Drupal\Core\Theme\AjaxBasePageNegotiator - // @see \Drupal\Core\Asset\LibraryDependencyResolverInterface::getMinimalRepresentativeSubset() - // @see system_js_settings_alter() var pageState = drupalSettings.ajaxPageState; options.data['ajax_page_state[theme]'] = pageState.theme; options.data['ajax_page_state[theme_token]'] = pageState.theme_token; options.data['ajax_page_state[libraries]'] = pageState.libraries; }; - /** - * Modify form values prior to form submission. - * - * @param {Array.<object>} form_values - * Processed form values. - * @param {jQuery} element - * The form node as a jQuery object. - * @param {object} options - * jQuery.ajax options. - */ - Drupal.Ajax.prototype.beforeSubmit = function (form_values, element, options) { - // This function is left empty to make it simple to override for modules - // that wish to add functionality here. - }; + Drupal.Ajax.prototype.beforeSubmit = function (form_values, element, options) {}; - /** - * Prepare the Ajax request before it is sent. - * - * @param {XMLHttpRequest} xmlhttprequest - * Native Ajax object. - * @param {object} options - * jQuery.ajax options. - */ Drupal.Ajax.prototype.beforeSend = function (xmlhttprequest, options) { - // For forms without file inputs, the jQuery Form plugin serializes the - // form values, and then calls jQuery's $.ajax() function, which invokes - // this handler. In this circumstance, options.extraData is never used. For - // forms with file inputs, the jQuery Form plugin uses the browser's normal - // form submission mechanism, but captures the response in a hidden IFRAME. - // In this circumstance, it calls this handler first, and then appends - // hidden fields to the form to submit the values in options.extraData. - // There is no simple way to know which submission mechanism will be used, - // so we add to extraData regardless, and allow it to be ignored in the - // former case. if (this.$form) { options.extraData = options.extraData || {}; - // Let the server know when the IFRAME submission mechanism is used. The - // server can use this information to wrap the JSON response in a - // TEXTAREA, as per http://jquery.malsup.com/form/#file-upload. options.extraData.ajax_iframe_upload = '1'; - // The triggering element is about to be disabled (see below), but if it - // contains a value (e.g., a checkbox, textfield, select, etc.), ensure - // that value is included in the submission. As per above, submissions - // that use $.ajax() are already serialized prior to the element being - // disabled, so this is only needed for IFRAME submissions. var v = $.fieldValue(this.element); if (v !== null) { options.extraData[this.element.name] = v; } } - // Disable the element that received the change to prevent user interface - // interaction while the Ajax request is in progress. ajax.ajaxing prevents - // the element from triggering a new request, but does not prevent the user - // from changing its value. $(this.element).prop('disabled', true); if (!this.progress || !this.progress.type) { return; } - // Insert progress indicator. var progressIndicatorMethod = 'setProgressIndicator' + this.progress.type.slice(0, 1).toUpperCase() + this.progress.type.slice(1).toLowerCase(); if (progressIndicatorMethod in this && typeof this[progressIndicatorMethod] === 'function') { this[progressIndicatorMethod].call(this); } }; - /** - * Sets the progress bar progress indicator. - */ Drupal.Ajax.prototype.setProgressIndicatorBar = function () { var progressBar = new Drupal.ProgressBar('ajax-progress-' + this.element.id, $.noop, this.progress.method, $.noop); if (this.progress.message) { @@ -813,9 +376,6 @@ $(this.element).after(this.progress.element); }; - /** - * Sets the throbber progress indicator. - */ Drupal.Ajax.prototype.setProgressIndicatorThrobber = function () { this.progress.element = $('<div class="ajax-progress ajax-progress-throbber"><div class="throbber"> </div></div>'); if (this.progress.message) { @@ -824,24 +384,12 @@ $(this.element).after(this.progress.element); }; - /** - * Sets the fullscreen progress indicator. - */ Drupal.Ajax.prototype.setProgressIndicatorFullscreen = function () { this.progress.element = $('<div class="ajax-progress ajax-progress-fullscreen"> </div>'); $('body').after(this.progress.element); }; - /** - * Handler for the form redirection completion. - * - * @param {Array.<Drupal.AjaxCommands~commandDefinition>} response - * Drupal Ajax response. - * @param {number} status - * XMLHttpRequest status. - */ Drupal.Ajax.prototype.success = function (response, status) { - // Remove the progress element. if (this.progress.element) { $(this.progress.element).remove(); } @@ -850,14 +398,8 @@ } $(this.element).prop('disabled', false); - // Save element's ancestors tree so if the element is removed from the dom - // we can try to refocus one of its parents. Using addBack reverse the - // result array, meaning that index 0 is the highest parent in the hierarchy - // in this situation it is usually a <form> element. var elementParents = $(this.element).parents('[data-drupal-selector]').addBack().toArray(); - // Track if any command is altering the focus so we can avoid changing the - // focus set by the Ajax command. var focusChanged = false; for (var i in response) { if (response.hasOwnProperty(i) && response[i].command && this.commands[response[i].command]) { @@ -868,9 +410,6 @@ } } - // If the focus hasn't be changed by the ajax commands, try to refocus the - // triggering element or one of its parents if that element does not exist - // anymore. if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) { var target = false; @@ -883,34 +422,14 @@ } } - // Reattach behaviors, if they were detached in beforeSerialize(). The - // attachBehaviors() called on the new content from processing the response - // commands is not sufficient, because behaviors from the entire form need - // to be reattached. if (this.$form) { var settings = this.settings || drupalSettings; Drupal.attachBehaviors(this.$form.get(0), settings); } - // Remove any response-specific settings so they don't get used on the next - // call by mistake. this.settings = null; }; - /** - * Build an effect object to apply an effect when adding new HTML. - * - * @param {object} response - * Drupal Ajax response. - * @param {string} [response.effect] - * Override the default value of {@link Drupal.Ajax#element_settings}. - * @param {string|number} [response.speed] - * Override the default value of {@link Drupal.Ajax#element_settings}. - * - * @return {object} - * Returns an object with `showEffect`, `hideEffect` and `showSpeed` - * properties. - */ Drupal.Ajax.prototype.getEffect = function (response) { var type = response.effect || this.effect; var speed = response.speed || this.speed; @@ -920,13 +439,11 @@ effect.showEffect = 'show'; effect.hideEffect = 'hide'; effect.showSpeed = ''; - } - else if (type === 'fade') { + } else if (type === 'fade') { effect.showEffect = 'fadeIn'; effect.hideEffect = 'fadeOut'; effect.showSpeed = speed; - } - else { + } else { effect.showEffect = type + 'Toggle'; effect.hideEffect = type + 'Toggle'; effect.showSpeed = speed; @@ -935,29 +452,18 @@ return effect; }; - /** - * Handler for the form redirection error. - * - * @param {object} xmlhttprequest - * Native XMLHttpRequest object. - * @param {string} uri - * Ajax Request URI. - * @param {string} [customMessage] - * Extra message to print with the Ajax error. - */ Drupal.Ajax.prototype.error = function (xmlhttprequest, uri, customMessage) { - // Remove the progress element. if (this.progress.element) { $(this.progress.element).remove(); } if (this.progress.object) { this.progress.object.stopMonitoring(); } - // Undo hide. + $(this.wrapper).show(); - // Re-enable the element. + $(this.element).prop('disabled', false); - // Reattach behaviors, if they were detached in beforeSerialize(). + if (this.$form) { var settings = this.settings || drupalSettings; Drupal.attachBehaviors(this.$form.get(0), settings); @@ -965,87 +471,21 @@ throw new Drupal.AjaxError(xmlhttprequest, uri, customMessage); }; - /** - * @typedef {object} Drupal.AjaxCommands~commandDefinition - * - * @prop {string} command - * @prop {string} [method] - * @prop {string} [selector] - * @prop {string} [data] - * @prop {object} [settings] - * @prop {bool} [asterisk] - * @prop {string} [text] - * @prop {string} [title] - * @prop {string} [url] - * @prop {object} [argument] - * @prop {string} [name] - * @prop {string} [value] - * @prop {string} [old] - * @prop {string} [new] - * @prop {bool} [merge] - * @prop {Array} [args] - * - * @see Drupal.AjaxCommands - */ - - /** - * Provide a series of commands that the client will perform. - * - * @constructor - */ Drupal.AjaxCommands = function () {}; Drupal.AjaxCommands.prototype = { - - /** - * Command to insert new content into the DOM. - * - * @param {Drupal.Ajax} ajax - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.data - * The data to use with the jQuery method. - * @param {string} [response.method] - * The jQuery DOM manipulation method to be used. - * @param {string} [response.selector] - * A optional jQuery selector string. - * @param {object} [response.settings] - * An optional array of settings that will be used. - * @param {number} [status] - * The XMLHttpRequest status. - */ - insert: function (ajax, response, status) { - // Get information from the response. If it is not there, default to - // our presets. + insert: function insert(ajax, response, status) { var $wrapper = response.selector ? $(response.selector) : $(ajax.wrapper); var method = response.method || ajax.method; var effect = ajax.getEffect(response); var settings; - // We don't know what response.data contains: it might be a string of text - // without HTML, so don't rely on jQuery correctly interpreting - // $(response.data) as new HTML rather than a CSS selector. Also, if - // response.data contains top-level text nodes, they get lost with either - // $(response.data) or $('<div></div>').replaceWith(response.data). var $new_content_wrapped = $('<div></div>').html(response.data); var $new_content = $new_content_wrapped.contents(); - // For legacy reasons, the effects processing code assumes that - // $new_content consists of a single top-level element. Also, it has not - // been sufficiently tested whether attachBehaviors() can be successfully - // called with a context object that includes top-level text nodes. - // However, to give developers full control of the HTML appearing in the - // page, and to enable Ajax content to be inserted in places where <div> - // elements are not allowed (e.g., within <table>, <tr>, and <span> - // parents), we check if the new content satisfies the requirement - // of a single top-level element, and only use the container <div> created - // above when it doesn't. For more information, please see - // https://www.drupal.org/node/736066. if ($new_content.length !== 1 || $new_content.get(0).nodeType !== 1) { $new_content = $new_content_wrapped; } - // If removing content from the wrapper, detach behaviors first. switch (method) { case 'html': case 'replaceWith': @@ -1056,73 +496,34 @@ Drupal.detachBehaviors($wrapper.get(0), settings); } - // Add the new content to the page. $wrapper[method]($new_content); - // Immediately hide the new content if we're using any effects. if (effect.showEffect !== 'show') { $new_content.hide(); } - // Determine which effect to use and what content will receive the - // effect, then show the new content. if ($new_content.find('.ajax-new-content').length > 0) { $new_content.find('.ajax-new-content').hide(); $new_content.show(); $new_content.find('.ajax-new-content')[effect.showEffect](effect.showSpeed); - } - else if (effect.showEffect !== 'show') { + } else if (effect.showEffect !== 'show') { $new_content[effect.showEffect](effect.showSpeed); } - // Attach all JavaScript behaviors to the new content, if it was - // successfully added to the page, this if statement allows - // `#ajax['wrapper']` to be optional. if ($new_content.parents('html').length > 0) { - // Apply any settings from the returned JSON if available. settings = response.settings || ajax.settings || drupalSettings; Drupal.attachBehaviors($new_content.get(0), settings); } }, - /** - * Command to remove a chunk from the page. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.selector - * A jQuery selector string. - * @param {object} [response.settings] - * An optional array of settings that will be used. - * @param {number} [status] - * The XMLHttpRequest status. - */ - remove: function (ajax, response, status) { + remove: function remove(ajax, response, status) { var settings = response.settings || ajax.settings || drupalSettings; $(response.selector).each(function () { Drupal.detachBehaviors(this, settings); - }) - .remove(); + }).remove(); }, - /** - * Command to mark a chunk changed. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The JSON response object from the Ajax request. - * @param {string} response.selector - * A jQuery selector string. - * @param {bool} [response.asterisk] - * An optional CSS selector. If specified, an asterisk will be - * appended to the HTML inside the provided selector. - * @param {number} [status] - * The request status. - */ - changed: function (ajax, response, status) { + changed: function changed(ajax, response, status) { var $element = $(response.selector); if (!$element.hasClass('ajax-changed')) { $element.addClass('ajax-changed'); @@ -1132,83 +533,23 @@ } }, - /** - * Command to provide an alert. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The JSON response from the Ajax request. - * @param {string} response.text - * The text that will be displayed in an alert dialog. - * @param {number} [status] - * The XMLHttpRequest status. - */ - alert: function (ajax, response, status) { + alert: function alert(ajax, response, status) { window.alert(response.text, response.title); }, - /** - * Command to set the window.location, redirecting the browser. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.url - * The URL to redirect to. - * @param {number} [status] - * The XMLHttpRequest status. - */ - redirect: function (ajax, response, status) { + redirect: function redirect(ajax, response, status) { window.location = response.url; }, - /** - * Command to provide the jQuery css() function. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.selector - * A jQuery selector string. - * @param {object} response.argument - * An array of key/value pairs to set in the CSS for the selector. - * @param {number} [status] - * The XMLHttpRequest status. - */ - css: function (ajax, response, status) { + css: function css(ajax, response, status) { $(response.selector).css(response.argument); }, - /** - * Command to set the settings used for other commands in this response. - * - * This method will also remove expired `drupalSettings.ajax` settings. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {bool} response.merge - * Determines whether the additional settings should be merged to the - * global settings. - * @param {object} response.settings - * Contains additional settings to add to the global settings. - * @param {number} [status] - * The XMLHttpRequest status. - */ - settings: function (ajax, response, status) { + settings: function settings(ajax, response, status) { var ajaxSettings = drupalSettings.ajax; - // Clean up drupalSettings.ajax. if (ajaxSettings) { Drupal.ajax.expired().forEach(function (instance) { - // If the Ajax object has been created through drupalSettings.ajax - // it will have a selector. When there is no selector the object - // has been initialized with a special class name picked up by the - // Ajax behavior. if (instance.selector) { var selector = instance.selector.replace('#', ''); @@ -1221,114 +562,31 @@ if (response.merge) { $.extend(true, drupalSettings, response.settings); - } - else { + } else { ajax.settings = response.settings; } }, - /** - * Command to attach data using jQuery's data API. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.name - * The name or key (in the key value pair) of the data attached to this - * selector. - * @param {string} response.selector - * A jQuery selector string. - * @param {string|object} response.value - * The value of to be attached. - * @param {number} [status] - * The XMLHttpRequest status. - */ - data: function (ajax, response, status) { + data: function data(ajax, response, status) { $(response.selector).data(response.name, response.value); }, - /** - * Command to apply a jQuery method. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {Array} response.args - * An array of arguments to the jQuery method, if any. - * @param {string} response.method - * The jQuery method to invoke. - * @param {string} response.selector - * A jQuery selector string. - * @param {number} [status] - * The XMLHttpRequest status. - */ - invoke: function (ajax, response, status) { + invoke: function invoke(ajax, response, status) { var $element = $(response.selector); $element[response.method].apply($element, response.args); }, - /** - * Command to restripe a table. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.selector - * A jQuery selector string. - * @param {number} [status] - * The XMLHttpRequest status. - */ - restripe: function (ajax, response, status) { - // :even and :odd are reversed because jQuery counts from 0 and - // we count from 1, so we're out of sync. - // Match immediate children of the parent element to allow nesting. - $(response.selector).find('> tbody > tr:visible, > tr:visible') - .removeClass('odd even') - .filter(':even').addClass('odd').end() - .filter(':odd').addClass('even'); + restripe: function restripe(ajax, response, status) { + $(response.selector).find('> tbody > tr:visible, > tr:visible').removeClass('odd even').filter(':even').addClass('odd').end().filter(':odd').addClass('even'); }, - /** - * Command to update a form's build ID. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.old - * The old form build ID. - * @param {string} response.new - * The new form build ID. - * @param {number} [status] - * The XMLHttpRequest status. - */ - update_build_id: function (ajax, response, status) { + update_build_id: function update_build_id(ajax, response, status) { $('input[name="form_build_id"][value="' + response.old + '"]').val(response.new); }, - /** - * Command to add css. - * - * Uses the proprietary addImport method if available as browsers which - * support that method ignore @import statements in dynamically added - * stylesheets. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.data - * A string that contains the styles to be added. - * @param {number} [status] - * The XMLHttpRequest status. - */ - add_css: function (ajax, response, status) { - // Add the styles in the normal way. + add_css: function add_css(ajax, response, status) { $('head').prepend(response.data); - // Add imports in the styles using the addImport method if available. + var match; var importMatch = /^@import url\("(.*)"\);$/igm; if (document.styleSheets[0].addImport && importMatch.test(response.data)) { @@ -1340,5 +598,4 @@ } } }; - -})(jQuery, window, Drupal, drupalSettings); +})(jQuery, window, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/misc/announce.es6.js b/core/misc/announce.es6.js new file mode 100644 index 000000000000..acf850a64147 --- /dev/null +++ b/core/misc/announce.es6.js @@ -0,0 +1,120 @@ +/** + * @file + * Adds an HTML element and method to trigger audio UAs to read system messages. + * + * Use {@link Drupal.announce} to indicate to screen reader users that an + * element on the page has changed state. For instance, if clicking a link + * loads 10 more items into a list, one might announce the change like this. + * + * @example + * $('#search-list') + * .on('itemInsert', function (event, data) { + * // Insert the new items. + * $(data.container.el).append(data.items.el); + * // Announce the change to the page contents. + * Drupal.announce(Drupal.t('@count items added to @container', + * {'@count': data.items.length, '@container': data.container.title} + * )); + * }); + */ + +(function (Drupal, debounce) { + + 'use strict'; + + var liveElement; + var announcements = []; + + /** + * Builds a div element with the aria-live attribute and add it to the DOM. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior for drupalAnnouce. + */ + Drupal.behaviors.drupalAnnounce = { + attach: function (context) { + // Create only one aria-live element. + if (!liveElement) { + liveElement = document.createElement('div'); + liveElement.id = 'drupal-live-announce'; + liveElement.className = 'visually-hidden'; + liveElement.setAttribute('aria-live', 'polite'); + liveElement.setAttribute('aria-busy', 'false'); + document.body.appendChild(liveElement); + } + } + }; + + /** + * Concatenates announcements to a single string; appends to the live region. + */ + function announce() { + var text = []; + var priority = 'polite'; + var announcement; + + // Create an array of announcement strings to be joined and appended to the + // aria live region. + var il = announcements.length; + for (var i = 0; i < il; i++) { + announcement = announcements.pop(); + text.unshift(announcement.text); + // If any of the announcements has a priority of assertive then the group + // of joined announcements will have this priority. + if (announcement.priority === 'assertive') { + priority = 'assertive'; + } + } + + if (text.length) { + // Clear the liveElement so that repeated strings will be read. + liveElement.innerHTML = ''; + // Set the busy state to true until the node changes are complete. + liveElement.setAttribute('aria-busy', 'true'); + // Set the priority to assertive, or default to polite. + liveElement.setAttribute('aria-live', priority); + // Print the text to the live region. Text should be run through + // Drupal.t() before being passed to Drupal.announce(). + liveElement.innerHTML = text.join('\n'); + // The live text area is updated. Allow the AT to announce the text. + liveElement.setAttribute('aria-busy', 'false'); + } + } + + /** + * Triggers audio UAs to read the supplied text. + * + * The aria-live region will only read the text that currently populates its + * text node. Replacing text quickly in rapid calls to announce results in + * only the text from the most recent call to {@link Drupal.announce} being + * read. By wrapping the call to announce in a debounce function, we allow for + * time for multiple calls to {@link Drupal.announce} to queue up their + * messages. These messages are then joined and append to the aria-live region + * as one text node. + * + * @param {string} text + * A string to be read by the UA. + * @param {string} [priority='polite'] + * A string to indicate the priority of the message. Can be either + * 'polite' or 'assertive'. + * + * @return {function} + * The return of the call to debounce. + * + * @see http://www.w3.org/WAI/PF/aria-practices/#liveprops + */ + Drupal.announce = function (text, priority) { + // Save the text and priority into a closure variable. Multiple simultaneous + // announcements will be concatenated and read in sequence. + announcements.push({ + text: text, + priority: priority + }); + // Immediately invoke the function that debounce returns. 200 ms is right at + // the cusp where humans notice a pause, so we will wait + // at most this much time before the set of queued announcements is read. + return (debounce(announce, 200)()); + }; +}(Drupal, Drupal.debounce)); diff --git a/core/misc/announce.js b/core/misc/announce.js index acf850a64147..27ca35861c12 100644 --- a/core/misc/announce.js +++ b/core/misc/announce.js @@ -1,22 +1,10 @@ /** - * @file - * Adds an HTML element and method to trigger audio UAs to read system messages. - * - * Use {@link Drupal.announce} to indicate to screen reader users that an - * element on the page has changed state. For instance, if clicking a link - * loads 10 more items into a list, one might announce the change like this. - * - * @example - * $('#search-list') - * .on('itemInsert', function (event, data) { - * // Insert the new items. - * $(data.container.el).append(data.items.el); - * // Announce the change to the page contents. - * Drupal.announce(Drupal.t('@count items added to @container', - * {'@count': data.items.length, '@container': data.container.title} - * )); - * }); - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/announce.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, debounce) { @@ -25,17 +13,8 @@ var liveElement; var announcements = []; - /** - * Builds a div element with the aria-live attribute and add it to the DOM. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the behavior for drupalAnnouce. - */ Drupal.behaviors.drupalAnnounce = { - attach: function (context) { - // Create only one aria-live element. + attach: function attach(context) { if (!liveElement) { liveElement = document.createElement('div'); liveElement.id = 'drupal-live-announce'; @@ -47,74 +26,40 @@ } }; - /** - * Concatenates announcements to a single string; appends to the live region. - */ function announce() { var text = []; var priority = 'polite'; var announcement; - // Create an array of announcement strings to be joined and appended to the - // aria live region. var il = announcements.length; for (var i = 0; i < il; i++) { announcement = announcements.pop(); text.unshift(announcement.text); - // If any of the announcements has a priority of assertive then the group - // of joined announcements will have this priority. + if (announcement.priority === 'assertive') { priority = 'assertive'; } } if (text.length) { - // Clear the liveElement so that repeated strings will be read. liveElement.innerHTML = ''; - // Set the busy state to true until the node changes are complete. + liveElement.setAttribute('aria-busy', 'true'); - // Set the priority to assertive, or default to polite. + liveElement.setAttribute('aria-live', priority); - // Print the text to the live region. Text should be run through - // Drupal.t() before being passed to Drupal.announce(). + liveElement.innerHTML = text.join('\n'); - // The live text area is updated. Allow the AT to announce the text. + liveElement.setAttribute('aria-busy', 'false'); } } - /** - * Triggers audio UAs to read the supplied text. - * - * The aria-live region will only read the text that currently populates its - * text node. Replacing text quickly in rapid calls to announce results in - * only the text from the most recent call to {@link Drupal.announce} being - * read. By wrapping the call to announce in a debounce function, we allow for - * time for multiple calls to {@link Drupal.announce} to queue up their - * messages. These messages are then joined and append to the aria-live region - * as one text node. - * - * @param {string} text - * A string to be read by the UA. - * @param {string} [priority='polite'] - * A string to indicate the priority of the message. Can be either - * 'polite' or 'assertive'. - * - * @return {function} - * The return of the call to debounce. - * - * @see http://www.w3.org/WAI/PF/aria-practices/#liveprops - */ Drupal.announce = function (text, priority) { - // Save the text and priority into a closure variable. Multiple simultaneous - // announcements will be concatenated and read in sequence. announcements.push({ text: text, priority: priority }); - // Immediately invoke the function that debounce returns. 200 ms is right at - // the cusp where humans notice a pause, so we will wait - // at most this much time before the set of queued announcements is read. - return (debounce(announce, 200)()); + + return debounce(announce, 200)(); }; -}(Drupal, Drupal.debounce)); +})(Drupal, Drupal.debounce); \ No newline at end of file diff --git a/core/misc/autocomplete.es6.js b/core/misc/autocomplete.es6.js new file mode 100644 index 000000000000..5a1c156d0bbf --- /dev/null +++ b/core/misc/autocomplete.es6.js @@ -0,0 +1,288 @@ +/** + * @file + * Autocomplete based on jQuery UI. + */ + +(function ($, Drupal) { + + 'use strict'; + + var autocomplete; + + /** + * Helper splitting terms from the autocomplete value. + * + * @function Drupal.autocomplete.splitValues + * + * @param {string} value + * The value being entered by the user. + * + * @return {Array} + * Array of values, split by comma. + */ + function autocompleteSplitValues(value) { + // We will match the value against comma-separated terms. + var result = []; + var quote = false; + var current = ''; + var valueLength = value.length; + var character; + + for (var i = 0; i < valueLength; i++) { + character = value.charAt(i); + if (character === '"') { + current += character; + quote = !quote; + } + else if (character === ',' && !quote) { + result.push(current.trim()); + current = ''; + } + else { + current += character; + } + } + if (value.length > 0) { + result.push($.trim(current)); + } + + return result; + } + + /** + * Returns the last value of an multi-value textfield. + * + * @function Drupal.autocomplete.extractLastTerm + * + * @param {string} terms + * The value of the field. + * + * @return {string} + * The last value of the input field. + */ + function extractLastTerm(terms) { + return autocomplete.splitValues(terms).pop(); + } + + /** + * The search handler is called before a search is performed. + * + * @function Drupal.autocomplete.options.search + * + * @param {object} event + * The event triggered. + * + * @return {bool} + * Whether to perform a search or not. + */ + function searchHandler(event) { + var options = autocomplete.options; + + if (options.isComposing) { + return false; + } + + var term = autocomplete.extractLastTerm(event.target.value); + // Abort search if the first character is in firstCharacterBlacklist. + if (term.length > 0 && options.firstCharacterBlacklist.indexOf(term[0]) !== -1) { + return false; + } + // Only search when the term is at least the minimum length. + return term.length >= options.minLength; + } + + /** + * JQuery UI autocomplete source callback. + * + * @param {object} request + * The request object. + * @param {function} response + * The function to call with the response. + */ + function sourceData(request, response) { + var elementId = this.element.attr('id'); + + if (!(elementId in autocomplete.cache)) { + autocomplete.cache[elementId] = {}; + } + + /** + * Filter through the suggestions removing all terms already tagged and + * display the available terms to the user. + * + * @param {object} suggestions + * Suggestions returned by the server. + */ + function showSuggestions(suggestions) { + var tagged = autocomplete.splitValues(request.term); + var il = tagged.length; + for (var i = 0; i < il; i++) { + var index = suggestions.indexOf(tagged[i]); + if (index >= 0) { + suggestions.splice(index, 1); + } + } + response(suggestions); + } + + /** + * Transforms the data object into an array and update autocomplete results. + * + * @param {object} data + * The data sent back from the server. + */ + function sourceCallbackHandler(data) { + autocomplete.cache[elementId][term] = data; + + // Send the new string array of terms to the jQuery UI list. + showSuggestions(data); + } + + // Get the desired term and construct the autocomplete URL for it. + var term = autocomplete.extractLastTerm(request.term); + + // Check if the term is already cached. + if (autocomplete.cache[elementId].hasOwnProperty(term)) { + showSuggestions(autocomplete.cache[elementId][term]); + } + else { + var options = $.extend({success: sourceCallbackHandler, data: {q: term}}, autocomplete.ajax); + $.ajax(this.element.attr('data-autocomplete-path'), options); + } + } + + /** + * Handles an autocompletefocus event. + * + * @return {bool} + * Always returns false. + */ + function focusHandler() { + return false; + } + + /** + * Handles an autocompleteselect event. + * + * @param {jQuery.Event} event + * The event triggered. + * @param {object} ui + * The jQuery UI settings object. + * + * @return {bool} + * Returns false to indicate the event status. + */ + function selectHandler(event, ui) { + var terms = autocomplete.splitValues(event.target.value); + // Remove the current input. + terms.pop(); + // Add the selected item. + if (ui.item.value.search(',') > 0) { + terms.push('"' + ui.item.value + '"'); + } + else { + terms.push(ui.item.value); + } + event.target.value = terms.join(', '); + // Return false to tell jQuery UI that we've filled in the value already. + return false; + } + + /** + * Override jQuery UI _renderItem function to output HTML by default. + * + * @param {jQuery} ul + * jQuery collection of the ul element. + * @param {object} item + * The list item to append. + * + * @return {jQuery} + * jQuery collection of the ul element. + */ + function renderItem(ul, item) { + return $('<li>') + .append($('<a>').html(item.label)) + .appendTo(ul); + } + + /** + * Attaches the autocomplete behavior to all required fields. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the autocomplete behaviors. + * @prop {Drupal~behaviorDetach} detach + * Detaches the autocomplete behaviors. + */ + Drupal.behaviors.autocomplete = { + attach: function (context) { + // Act on textfields with the "form-autocomplete" class. + var $autocomplete = $(context).find('input.form-autocomplete').once('autocomplete'); + if ($autocomplete.length) { + // Allow options to be overriden per instance. + var blacklist = $autocomplete.attr('data-autocomplete-first-character-blacklist'); + $.extend(autocomplete.options, { + firstCharacterBlacklist: (blacklist) ? blacklist : '' + }); + // Use jQuery UI Autocomplete on the textfield. + $autocomplete.autocomplete(autocomplete.options) + .each(function () { + $(this).data('ui-autocomplete')._renderItem = autocomplete.options.renderItem; + }); + + // Use CompositionEvent to handle IME inputs. It requests remote server on "compositionend" event only. + $autocomplete.on('compositionstart.autocomplete', function () { + autocomplete.options.isComposing = true; + }); + $autocomplete.on('compositionend.autocomplete', function () { + autocomplete.options.isComposing = false; + }); + } + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + $(context).find('input.form-autocomplete') + .removeOnce('autocomplete') + .autocomplete('destroy'); + } + } + }; + + /** + * Autocomplete object implementation. + * + * @namespace Drupal.autocomplete + */ + autocomplete = { + cache: {}, + // Exposes options to allow overriding by contrib. + splitValues: autocompleteSplitValues, + extractLastTerm: extractLastTerm, + // jQuery UI autocomplete options. + + /** + * JQuery UI option object. + * + * @name Drupal.autocomplete.options + */ + options: { + source: sourceData, + focus: focusHandler, + search: searchHandler, + select: selectHandler, + renderItem: renderItem, + minLength: 1, + // Custom options, used by Drupal.autocomplete. + firstCharacterBlacklist: '', + // Custom options, indicate IME usage status. + isComposing: false + }, + ajax: { + dataType: 'json' + } + }; + + Drupal.autocomplete = autocomplete; + +})(jQuery, Drupal); diff --git a/core/misc/autocomplete.js b/core/misc/autocomplete.js index 5a1c156d0bbf..04cc50a98a86 100644 --- a/core/misc/autocomplete.js +++ b/core/misc/autocomplete.js @@ -1,7 +1,10 @@ /** - * @file - * Autocomplete based on jQuery UI. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/autocomplete.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { @@ -9,19 +12,7 @@ var autocomplete; - /** - * Helper splitting terms from the autocomplete value. - * - * @function Drupal.autocomplete.splitValues - * - * @param {string} value - * The value being entered by the user. - * - * @return {Array} - * Array of values, split by comma. - */ function autocompleteSplitValues(value) { - // We will match the value against comma-separated terms. var result = []; var quote = false; var current = ''; @@ -33,12 +24,10 @@ if (character === '"') { current += character; quote = !quote; - } - else if (character === ',' && !quote) { + } else if (character === ',' && !quote) { result.push(current.trim()); current = ''; - } - else { + } else { current += character; } } @@ -49,32 +38,10 @@ return result; } - /** - * Returns the last value of an multi-value textfield. - * - * @function Drupal.autocomplete.extractLastTerm - * - * @param {string} terms - * The value of the field. - * - * @return {string} - * The last value of the input field. - */ function extractLastTerm(terms) { return autocomplete.splitValues(terms).pop(); } - /** - * The search handler is called before a search is performed. - * - * @function Drupal.autocomplete.options.search - * - * @param {object} event - * The event triggered. - * - * @return {bool} - * Whether to perform a search or not. - */ function searchHandler(event) { var options = autocomplete.options; @@ -83,22 +50,14 @@ } var term = autocomplete.extractLastTerm(event.target.value); - // Abort search if the first character is in firstCharacterBlacklist. + if (term.length > 0 && options.firstCharacterBlacklist.indexOf(term[0]) !== -1) { return false; } - // Only search when the term is at least the minimum length. + return term.length >= options.minLength; } - /** - * JQuery UI autocomplete source callback. - * - * @param {object} request - * The request object. - * @param {function} response - * The function to call with the response. - */ function sourceData(request, response) { var elementId = this.element.attr('id'); @@ -106,13 +65,6 @@ autocomplete.cache[elementId] = {}; } - /** - * Filter through the suggestions removing all terms already tagged and - * display the available terms to the user. - * - * @param {object} suggestions - * Suggestions returned by the server. - */ function showSuggestions(suggestions) { var tagged = autocomplete.splitValues(request.term); var il = tagged.length; @@ -125,113 +77,58 @@ response(suggestions); } - /** - * Transforms the data object into an array and update autocomplete results. - * - * @param {object} data - * The data sent back from the server. - */ function sourceCallbackHandler(data) { autocomplete.cache[elementId][term] = data; - // Send the new string array of terms to the jQuery UI list. showSuggestions(data); } - // Get the desired term and construct the autocomplete URL for it. var term = autocomplete.extractLastTerm(request.term); - // Check if the term is already cached. if (autocomplete.cache[elementId].hasOwnProperty(term)) { showSuggestions(autocomplete.cache[elementId][term]); - } - else { - var options = $.extend({success: sourceCallbackHandler, data: {q: term}}, autocomplete.ajax); + } else { + var options = $.extend({ success: sourceCallbackHandler, data: { q: term } }, autocomplete.ajax); $.ajax(this.element.attr('data-autocomplete-path'), options); } } - /** - * Handles an autocompletefocus event. - * - * @return {bool} - * Always returns false. - */ function focusHandler() { return false; } - /** - * Handles an autocompleteselect event. - * - * @param {jQuery.Event} event - * The event triggered. - * @param {object} ui - * The jQuery UI settings object. - * - * @return {bool} - * Returns false to indicate the event status. - */ function selectHandler(event, ui) { var terms = autocomplete.splitValues(event.target.value); - // Remove the current input. + terms.pop(); - // Add the selected item. + if (ui.item.value.search(',') > 0) { terms.push('"' + ui.item.value + '"'); - } - else { + } else { terms.push(ui.item.value); } event.target.value = terms.join(', '); - // Return false to tell jQuery UI that we've filled in the value already. + return false; } - /** - * Override jQuery UI _renderItem function to output HTML by default. - * - * @param {jQuery} ul - * jQuery collection of the ul element. - * @param {object} item - * The list item to append. - * - * @return {jQuery} - * jQuery collection of the ul element. - */ function renderItem(ul, item) { - return $('<li>') - .append($('<a>').html(item.label)) - .appendTo(ul); + return $('<li>').append($('<a>').html(item.label)).appendTo(ul); } - /** - * Attaches the autocomplete behavior to all required fields. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the autocomplete behaviors. - * @prop {Drupal~behaviorDetach} detach - * Detaches the autocomplete behaviors. - */ Drupal.behaviors.autocomplete = { - attach: function (context) { - // Act on textfields with the "form-autocomplete" class. + attach: function attach(context) { var $autocomplete = $(context).find('input.form-autocomplete').once('autocomplete'); if ($autocomplete.length) { - // Allow options to be overriden per instance. var blacklist = $autocomplete.attr('data-autocomplete-first-character-blacklist'); $.extend(autocomplete.options, { - firstCharacterBlacklist: (blacklist) ? blacklist : '' + firstCharacterBlacklist: blacklist ? blacklist : '' + }); + + $autocomplete.autocomplete(autocomplete.options).each(function () { + $(this).data('ui-autocomplete')._renderItem = autocomplete.options.renderItem; }); - // Use jQuery UI Autocomplete on the textfield. - $autocomplete.autocomplete(autocomplete.options) - .each(function () { - $(this).data('ui-autocomplete')._renderItem = autocomplete.options.renderItem; - }); - // Use CompositionEvent to handle IME inputs. It requests remote server on "compositionend" event only. $autocomplete.on('compositionstart.autocomplete', function () { autocomplete.options.isComposing = true; }); @@ -240,32 +137,19 @@ }); } }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload') { - $(context).find('input.form-autocomplete') - .removeOnce('autocomplete') - .autocomplete('destroy'); + $(context).find('input.form-autocomplete').removeOnce('autocomplete').autocomplete('destroy'); } } }; - /** - * Autocomplete object implementation. - * - * @namespace Drupal.autocomplete - */ autocomplete = { cache: {}, - // Exposes options to allow overriding by contrib. + splitValues: autocompleteSplitValues, extractLastTerm: extractLastTerm, - // jQuery UI autocomplete options. - /** - * JQuery UI option object. - * - * @name Drupal.autocomplete.options - */ options: { source: sourceData, focus: focusHandler, @@ -273,9 +157,9 @@ select: selectHandler, renderItem: renderItem, minLength: 1, - // Custom options, used by Drupal.autocomplete. + firstCharacterBlacklist: '', - // Custom options, indicate IME usage status. + isComposing: false }, ajax: { @@ -284,5 +168,4 @@ }; Drupal.autocomplete = autocomplete; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/batch.es6.js b/core/misc/batch.es6.js new file mode 100644 index 000000000000..411badba4272 --- /dev/null +++ b/core/misc/batch.es6.js @@ -0,0 +1,46 @@ +/** + * @file + * Drupal's batch API. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Attaches the batch behavior to progress bars. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.batch = { + attach: function (context, settings) { + var batch = settings.batch; + var $progress = $('[data-drupal-progress]').once('batch'); + var progressBar; + + // Success: redirect to the summary. + function updateCallback(progress, status, pb) { + if (progress === '100') { + pb.stopMonitoring(); + window.location = batch.uri + '&op=finished'; + } + } + + function errorCallback(pb) { + $progress.prepend($('<p class="error"></p>').html(batch.errorMessage)); + $('#wait').hide(); + } + + if ($progress.length) { + progressBar = new Drupal.ProgressBar('updateprogress', updateCallback, 'POST', errorCallback); + progressBar.setProgress(-1, batch.initMessage); + progressBar.startMonitoring(batch.uri + '&op=do', 10); + // Remove HTML from no-js progress bar. + $progress.empty(); + // Append the JS progressbar element. + $progress.append(progressBar.element); + } + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/batch.js b/core/misc/batch.js index 411badba4272..6a2858bfa938 100644 --- a/core/misc/batch.js +++ b/core/misc/batch.js @@ -1,24 +1,21 @@ /** - * @file - * Drupal's batch API. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/batch.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Attaches the batch behavior to progress bars. - * - * @type {Drupal~behavior} - */ Drupal.behaviors.batch = { - attach: function (context, settings) { + attach: function attach(context, settings) { var batch = settings.batch; var $progress = $('[data-drupal-progress]').once('batch'); var progressBar; - // Success: redirect to the summary. function updateCallback(progress, status, pb) { if (progress === '100') { pb.stopMonitoring(); @@ -35,12 +32,11 @@ progressBar = new Drupal.ProgressBar('updateprogress', updateCallback, 'POST', errorCallback); progressBar.setProgress(-1, batch.initMessage); progressBar.startMonitoring(batch.uri + '&op=do', 10); - // Remove HTML from no-js progress bar. + $progress.empty(); - // Append the JS progressbar element. + $progress.append(progressBar.element); } } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/collapse.es6.js b/core/misc/collapse.es6.js new file mode 100644 index 000000000000..767325ec1ad7 --- /dev/null +++ b/core/misc/collapse.es6.js @@ -0,0 +1,146 @@ +/** + * @file + * Polyfill for HTML5 details elements. + */ + +(function ($, Modernizr, Drupal) { + + 'use strict'; + + /** + * The collapsible details object represents a single details element. + * + * @constructor Drupal.CollapsibleDetails + * + * @param {HTMLElement} node + * The details element. + */ + function CollapsibleDetails(node) { + this.$node = $(node); + this.$node.data('details', this); + // Expand details if there are errors inside, or if it contains an + // element that is targeted by the URI fragment identifier. + var anchor = location.hash && location.hash !== '#' ? ', ' + location.hash : ''; + if (this.$node.find('.error' + anchor).length) { + this.$node.attr('open', true); + } + // Initialize and setup the summary, + this.setupSummary(); + // Initialize and setup the legend. + this.setupLegend(); + } + + $.extend(CollapsibleDetails, /** @lends Drupal.CollapsibleDetails */{ + + /** + * Holds references to instantiated CollapsibleDetails objects. + * + * @type {Array.<Drupal.CollapsibleDetails>} + */ + instances: [] + }); + + $.extend(CollapsibleDetails.prototype, /** @lends Drupal.CollapsibleDetails# */{ + + /** + * Initialize and setup summary events and markup. + * + * @fires event:summaryUpdated + * + * @listens event:summaryUpdated + */ + setupSummary: function () { + this.$summary = $('<span class="summary"></span>'); + this.$node + .on('summaryUpdated', $.proxy(this.onSummaryUpdated, this)) + .trigger('summaryUpdated'); + }, + + /** + * Initialize and setup legend markup. + */ + setupLegend: function () { + // Turn the summary into a clickable link. + var $legend = this.$node.find('> summary'); + + $('<span class="details-summary-prefix visually-hidden"></span>') + .append(this.$node.attr('open') ? Drupal.t('Hide') : Drupal.t('Show')) + .prependTo($legend) + .after(document.createTextNode(' ')); + + // .wrapInner() does not retain bound events. + $('<a class="details-title"></a>') + .attr('href', '#' + this.$node.attr('id')) + .prepend($legend.contents()) + .appendTo($legend); + + $legend + .append(this.$summary) + .on('click', $.proxy(this.onLegendClick, this)); + }, + + /** + * Handle legend clicks. + * + * @param {jQuery.Event} e + * The event triggered. + */ + onLegendClick: function (e) { + this.toggle(); + e.preventDefault(); + }, + + /** + * Update summary. + */ + onSummaryUpdated: function () { + var text = $.trim(this.$node.drupalGetSummary()); + this.$summary.html(text ? ' (' + text + ')' : ''); + }, + + /** + * Toggle the visibility of a details element using smooth animations. + */ + toggle: function () { + var isOpen = !!this.$node.attr('open'); + var $summaryPrefix = this.$node.find('> summary span.details-summary-prefix'); + if (isOpen) { + $summaryPrefix.html(Drupal.t('Show')); + } + else { + $summaryPrefix.html(Drupal.t('Hide')); + } + // Delay setting the attribute to emulate chrome behavior and make + // details-aria.js work as expected with this polyfill. + setTimeout(function () { + this.$node.attr('open', !isOpen); + }.bind(this), 0); + } + }); + + /** + * Polyfill HTML5 details element. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for the details element. + */ + Drupal.behaviors.collapse = { + attach: function (context) { + if (Modernizr.details) { + return; + } + var $collapsibleDetails = $(context).find('details').once('collapse').addClass('collapse-processed'); + if ($collapsibleDetails.length) { + for (var i = 0; i < $collapsibleDetails.length; i++) { + CollapsibleDetails.instances.push(new CollapsibleDetails($collapsibleDetails[i])); + } + } + } + }; + + // Expose constructor in the public space. + Drupal.CollapsibleDetails = CollapsibleDetails; + +})(jQuery, Modernizr, Drupal); diff --git a/core/misc/collapse.js b/core/misc/collapse.js index 767325ec1ad7..49ed00fee242 100644 --- a/core/misc/collapse.js +++ b/core/misc/collapse.js @@ -1,133 +1,76 @@ /** - * @file - * Polyfill for HTML5 details elements. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/collapse.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Modernizr, Drupal) { 'use strict'; - /** - * The collapsible details object represents a single details element. - * - * @constructor Drupal.CollapsibleDetails - * - * @param {HTMLElement} node - * The details element. - */ function CollapsibleDetails(node) { this.$node = $(node); this.$node.data('details', this); - // Expand details if there are errors inside, or if it contains an - // element that is targeted by the URI fragment identifier. + var anchor = location.hash && location.hash !== '#' ? ', ' + location.hash : ''; if (this.$node.find('.error' + anchor).length) { this.$node.attr('open', true); } - // Initialize and setup the summary, + this.setupSummary(); - // Initialize and setup the legend. + this.setupLegend(); } - $.extend(CollapsibleDetails, /** @lends Drupal.CollapsibleDetails */{ - - /** - * Holds references to instantiated CollapsibleDetails objects. - * - * @type {Array.<Drupal.CollapsibleDetails>} - */ + $.extend(CollapsibleDetails, { instances: [] }); - $.extend(CollapsibleDetails.prototype, /** @lends Drupal.CollapsibleDetails# */{ - - /** - * Initialize and setup summary events and markup. - * - * @fires event:summaryUpdated - * - * @listens event:summaryUpdated - */ - setupSummary: function () { + $.extend(CollapsibleDetails.prototype, { + setupSummary: function setupSummary() { this.$summary = $('<span class="summary"></span>'); - this.$node - .on('summaryUpdated', $.proxy(this.onSummaryUpdated, this)) - .trigger('summaryUpdated'); + this.$node.on('summaryUpdated', $.proxy(this.onSummaryUpdated, this)).trigger('summaryUpdated'); }, - /** - * Initialize and setup legend markup. - */ - setupLegend: function () { - // Turn the summary into a clickable link. + setupLegend: function setupLegend() { var $legend = this.$node.find('> summary'); - $('<span class="details-summary-prefix visually-hidden"></span>') - .append(this.$node.attr('open') ? Drupal.t('Hide') : Drupal.t('Show')) - .prependTo($legend) - .after(document.createTextNode(' ')); + $('<span class="details-summary-prefix visually-hidden"></span>').append(this.$node.attr('open') ? Drupal.t('Hide') : Drupal.t('Show')).prependTo($legend).after(document.createTextNode(' ')); - // .wrapInner() does not retain bound events. - $('<a class="details-title"></a>') - .attr('href', '#' + this.$node.attr('id')) - .prepend($legend.contents()) - .appendTo($legend); + $('<a class="details-title"></a>').attr('href', '#' + this.$node.attr('id')).prepend($legend.contents()).appendTo($legend); - $legend - .append(this.$summary) - .on('click', $.proxy(this.onLegendClick, this)); + $legend.append(this.$summary).on('click', $.proxy(this.onLegendClick, this)); }, - /** - * Handle legend clicks. - * - * @param {jQuery.Event} e - * The event triggered. - */ - onLegendClick: function (e) { + onLegendClick: function onLegendClick(e) { this.toggle(); e.preventDefault(); }, - /** - * Update summary. - */ - onSummaryUpdated: function () { + onSummaryUpdated: function onSummaryUpdated() { var text = $.trim(this.$node.drupalGetSummary()); this.$summary.html(text ? ' (' + text + ')' : ''); }, - /** - * Toggle the visibility of a details element using smooth animations. - */ - toggle: function () { + toggle: function toggle() { var isOpen = !!this.$node.attr('open'); var $summaryPrefix = this.$node.find('> summary span.details-summary-prefix'); if (isOpen) { $summaryPrefix.html(Drupal.t('Show')); - } - else { + } else { $summaryPrefix.html(Drupal.t('Hide')); } - // Delay setting the attribute to emulate chrome behavior and make - // details-aria.js work as expected with this polyfill. + setTimeout(function () { this.$node.attr('open', !isOpen); }.bind(this), 0); } }); - /** - * Polyfill HTML5 details element. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior for the details element. - */ Drupal.behaviors.collapse = { - attach: function (context) { + attach: function attach(context) { if (Modernizr.details) { return; } @@ -140,7 +83,5 @@ } }; - // Expose constructor in the public space. Drupal.CollapsibleDetails = CollapsibleDetails; - -})(jQuery, Modernizr, Drupal); +})(jQuery, Modernizr, Drupal); \ No newline at end of file diff --git a/core/misc/date.es6.js b/core/misc/date.es6.js new file mode 100644 index 000000000000..8b6b71cb1db0 --- /dev/null +++ b/core/misc/date.es6.js @@ -0,0 +1,56 @@ +/** + * @file + * Polyfill for HTML5 date input. + */ + +(function ($, Modernizr, Drupal) { + + 'use strict'; + + /** + * Attach datepicker fallback on date elements. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior. Accepts in `settings.date` an object listing + * elements to process, keyed by the HTML ID of the form element containing + * the human-readable value. Each element is an datepicker settings object. + * @prop {Drupal~behaviorDetach} detach + * Detach the behavior destroying datepickers on effected elements. + */ + Drupal.behaviors.date = { + attach: function (context, settings) { + var $context = $(context); + // Skip if date are supported by the browser. + if (Modernizr.inputtypes.date === true) { + return; + } + $context.find('input[data-drupal-date-format]').once('datePicker').each(function () { + var $input = $(this); + var datepickerSettings = {}; + var dateFormat = $input.data('drupalDateFormat'); + // The date format is saved in PHP style, we need to convert to jQuery + // datepicker. + datepickerSettings.dateFormat = dateFormat + .replace('Y', 'yy') + .replace('m', 'mm') + .replace('d', 'dd'); + // Add min and max date if set on the input. + if ($input.attr('min')) { + datepickerSettings.minDate = $input.attr('min'); + } + if ($input.attr('max')) { + datepickerSettings.maxDate = $input.attr('max'); + } + $input.datepicker(datepickerSettings); + }); + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + $(context).find('input[data-drupal-date-format]').findOnce('datePicker').datepicker('destroy'); + } + } + }; + +})(jQuery, Modernizr, Drupal); diff --git a/core/misc/date.js b/core/misc/date.js index 8b6b71cb1db0..89dcd7abea3f 100644 --- a/core/misc/date.js +++ b/core/misc/date.js @@ -1,28 +1,19 @@ /** - * @file - * Polyfill for HTML5 date input. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/date.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Modernizr, Drupal) { 'use strict'; - /** - * Attach datepicker fallback on date elements. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the behavior. Accepts in `settings.date` an object listing - * elements to process, keyed by the HTML ID of the form element containing - * the human-readable value. Each element is an datepicker settings object. - * @prop {Drupal~behaviorDetach} detach - * Detach the behavior destroying datepickers on effected elements. - */ Drupal.behaviors.date = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $context = $(context); - // Skip if date are supported by the browser. + if (Modernizr.inputtypes.date === true) { return; } @@ -30,13 +21,9 @@ var $input = $(this); var datepickerSettings = {}; var dateFormat = $input.data('drupalDateFormat'); - // The date format is saved in PHP style, we need to convert to jQuery - // datepicker. - datepickerSettings.dateFormat = dateFormat - .replace('Y', 'yy') - .replace('m', 'mm') - .replace('d', 'dd'); - // Add min and max date if set on the input. + + datepickerSettings.dateFormat = dateFormat.replace('Y', 'yy').replace('m', 'mm').replace('d', 'dd'); + if ($input.attr('min')) { datepickerSettings.minDate = $input.attr('min'); } @@ -46,11 +33,10 @@ $input.datepicker(datepickerSettings); }); }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload') { $(context).find('input[data-drupal-date-format]').findOnce('datePicker').datepicker('destroy'); } } }; - -})(jQuery, Modernizr, Drupal); +})(jQuery, Modernizr, Drupal); \ No newline at end of file diff --git a/core/misc/debounce.es6.js b/core/misc/debounce.es6.js new file mode 100644 index 000000000000..995e3d713593 --- /dev/null +++ b/core/misc/debounce.es6.js @@ -0,0 +1,52 @@ +/** + * @file + * Adapted from underscore.js with the addition Drupal namespace. + */ + +/** + * Limits the invocations of a function in a given time frame. + * + * The debounce function wrapper should be used sparingly. One clear use case + * is limiting the invocation of a callback attached to the window resize event. + * + * Before using the debounce function wrapper, consider first whether the + * callback could be attached to an event that fires less frequently or if the + * function can be written in such a way that it is only invoked under specific + * conditions. + * + * @param {function} func + * The function to be invoked. + * @param {number} wait + * The time period within which the callback function should only be + * invoked once. For example if the wait period is 250ms, then the callback + * will only be called at most 4 times per second. + * @param {bool} immediate + * Whether we wait at the beginning or end to execute the function. + * + * @return {function} + * The debounced function. + */ +Drupal.debounce = function (func, wait, immediate) { + + 'use strict'; + + var timeout; + var result; + return function () { + var context = this; + var args = arguments; + var later = function () { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + }; +}; diff --git a/core/misc/debounce.js b/core/misc/debounce.js index 995e3d713593..2ad1f872556c 100644 --- a/core/misc/debounce.js +++ b/core/misc/debounce.js @@ -1,31 +1,11 @@ /** - * @file - * Adapted from underscore.js with the addition Drupal namespace. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/debounce.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ -/** - * Limits the invocations of a function in a given time frame. - * - * The debounce function wrapper should be used sparingly. One clear use case - * is limiting the invocation of a callback attached to the window resize event. - * - * Before using the debounce function wrapper, consider first whether the - * callback could be attached to an event that fires less frequently or if the - * function can be written in such a way that it is only invoked under specific - * conditions. - * - * @param {function} func - * The function to be invoked. - * @param {number} wait - * The time period within which the callback function should only be - * invoked once. For example if the wait period is 250ms, then the callback - * will only be called at most 4 times per second. - * @param {bool} immediate - * Whether we wait at the beginning or end to execute the function. - * - * @return {function} - * The debounced function. - */ Drupal.debounce = function (func, wait, immediate) { 'use strict'; @@ -35,7 +15,7 @@ Drupal.debounce = function (func, wait, immediate) { return function () { var context = this; var args = arguments; - var later = function () { + var later = function later() { timeout = null; if (!immediate) { result = func.apply(context, args); @@ -49,4 +29,4 @@ Drupal.debounce = function (func, wait, immediate) { } return result; }; -}; +}; \ No newline at end of file diff --git a/core/misc/details-aria.es6.js b/core/misc/details-aria.es6.js new file mode 100644 index 000000000000..d341422351c8 --- /dev/null +++ b/core/misc/details-aria.es6.js @@ -0,0 +1,29 @@ +/** + * @file + * Add aria attribute handling for details and summary elements. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Handles `aria-expanded` and `aria-pressed` attributes on details elements. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.detailsAria = { + attach: function () { + $('body').once('detailsAria').on('click.detailsAria', 'summary', function (event) { + var $summary = $(event.currentTarget); + var open = $(event.currentTarget.parentNode).attr('open') === 'open' ? 'false' : 'true'; + + $summary.attr({ + 'aria-expanded': open, + 'aria-pressed': open + }); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/details-aria.js b/core/misc/details-aria.js index d341422351c8..833146cf4759 100644 --- a/core/misc/details-aria.js +++ b/core/misc/details-aria.js @@ -1,19 +1,17 @@ /** - * @file - * Add aria attribute handling for details and summary elements. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/details-aria.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Handles `aria-expanded` and `aria-pressed` attributes on details elements. - * - * @type {Drupal~behavior} - */ Drupal.behaviors.detailsAria = { - attach: function () { + attach: function attach() { $('body').once('detailsAria').on('click.detailsAria', 'summary', function (event) { var $summary = $(event.currentTarget); var open = $(event.currentTarget.parentNode).attr('open') === 'open' ? 'false' : 'true'; @@ -25,5 +23,4 @@ }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/dialog/dialog.ajax.es6.js b/core/misc/dialog/dialog.ajax.es6.js new file mode 100644 index 000000000000..3f1b0c2efd6f --- /dev/null +++ b/core/misc/dialog/dialog.ajax.es6.js @@ -0,0 +1,246 @@ +/** + * @file + * Extends the Drupal AJAX functionality to integrate the dialog API. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Initialize dialogs for Ajax purposes. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behaviors for dialog ajax functionality. + */ + Drupal.behaviors.dialog = { + attach: function (context, settings) { + var $context = $(context); + + // Provide a known 'drupal-modal' DOM element for Drupal-based modal + // dialogs. Non-modal dialogs are responsible for creating their own + // elements, since there can be multiple non-modal dialogs at a time. + if (!$('#drupal-modal').length) { + // Add 'ui-front' jQuery UI class so jQuery UI widgets like autocomplete + // sit on top of dialogs. For more information see + // http://api.jqueryui.com/theming/stacking-elements/. + $('<div id="drupal-modal" class="ui-front"/>').hide().appendTo('body'); + } + + // Special behaviors specific when attaching content within a dialog. + // These behaviors usually fire after a validation error inside a dialog. + var $dialog = $context.closest('.ui-dialog-content'); + if ($dialog.length) { + // Remove and replace the dialog buttons with those from the new form. + if ($dialog.dialog('option', 'drupalAutoButtons')) { + // Trigger an event to detect/sync changes to buttons. + $dialog.trigger('dialogButtonsChange'); + } + + // Force focus on the modal when the behavior is run. + $dialog.dialog('widget').trigger('focus'); + } + + var originalClose = settings.dialog.close; + // Overwrite the close method to remove the dialog on closing. + settings.dialog.close = function (event) { + originalClose.apply(settings.dialog, arguments); + $(event.target).remove(); + }; + }, + + /** + * Scan a dialog for any primary buttons and move them to the button area. + * + * @param {jQuery} $dialog + * An jQuery object containing the element that is the dialog target. + * + * @return {Array} + * An array of buttons that need to be added to the button area. + */ + prepareDialogButtons: function ($dialog) { + var buttons = []; + var $buttons = $dialog.find('.form-actions input[type=submit], .form-actions a.button'); + $buttons.each(function () { + // Hidden form buttons need special attention. For browser consistency, + // the button needs to be "visible" in order to have the enter key fire + // the form submit event. So instead of a simple "hide" or + // "display: none", we set its dimensions to zero. + // See http://mattsnider.com/how-forms-submit-when-pressing-enter/ + var $originalButton = $(this).css({ + display: 'block', + width: 0, + height: 0, + padding: 0, + border: 0, + overflow: 'hidden' + }); + buttons.push({ + text: $originalButton.html() || $originalButton.attr('value'), + class: $originalButton.attr('class'), + click: function (e) { + // If the original button is an anchor tag, triggering the "click" + // event will not simulate a click. Use the click method instead. + if ($originalButton.is('a')) { + $originalButton[0].click(); + } + else { + $originalButton.trigger('mousedown').trigger('mouseup').trigger('click'); + e.preventDefault(); + } + } + }); + }); + return buttons; + } + }; + + /** + * Command to open a dialog. + * + * @param {Drupal.Ajax} ajax + * The Drupal Ajax object. + * @param {object} response + * Object holding the server response. + * @param {number} [status] + * The HTTP status code. + * + * @return {bool|undefined} + * Returns false if there was no selector property in the response object. + */ + Drupal.AjaxCommands.prototype.openDialog = function (ajax, response, status) { + if (!response.selector) { + return false; + } + var $dialog = $(response.selector); + if (!$dialog.length) { + // Create the element if needed. + $dialog = $('<div id="' + response.selector.replace(/^#/, '') + '" class="ui-front"/>').appendTo('body'); + } + // Set up the wrapper, if there isn't one. + if (!ajax.wrapper) { + ajax.wrapper = $dialog.attr('id'); + } + + // Use the ajax.js insert command to populate the dialog contents. + response.command = 'insert'; + response.method = 'html'; + ajax.commands.insert(ajax, response, status); + + // Move the buttons to the jQuery UI dialog buttons area. + if (!response.dialogOptions.buttons) { + response.dialogOptions.drupalAutoButtons = true; + response.dialogOptions.buttons = Drupal.behaviors.dialog.prepareDialogButtons($dialog); + } + + // Bind dialogButtonsChange. + $dialog.on('dialogButtonsChange', function () { + var buttons = Drupal.behaviors.dialog.prepareDialogButtons($dialog); + $dialog.dialog('option', 'buttons', buttons); + }); + + // Open the dialog itself. + response.dialogOptions = response.dialogOptions || {}; + var dialog = Drupal.dialog($dialog.get(0), response.dialogOptions); + if (response.dialogOptions.modal) { + dialog.showModal(); + } + else { + dialog.show(); + } + + // Add the standard Drupal class for buttons for style consistency. + $dialog.parent().find('.ui-dialog-buttonset').addClass('form-actions'); + }; + + /** + * Command to close a dialog. + * + * If no selector is given, it defaults to trying to close the modal. + * + * @param {Drupal.Ajax} [ajax] + * The ajax object. + * @param {object} response + * Object holding the server response. + * @param {string} response.selector + * The selector of the dialog. + * @param {bool} response.persist + * Whether to persist the dialog element or not. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.closeDialog = function (ajax, response, status) { + var $dialog = $(response.selector); + if ($dialog.length) { + Drupal.dialog($dialog.get(0)).close(); + if (!response.persist) { + $dialog.remove(); + } + } + + // Unbind dialogButtonsChange. + $dialog.off('dialogButtonsChange'); + }; + + /** + * Command to set a dialog property. + * + * JQuery UI specific way of setting dialog options. + * + * @param {Drupal.Ajax} [ajax] + * The Drupal Ajax object. + * @param {object} response + * Object holding the server response. + * @param {string} response.selector + * Selector for the dialog element. + * @param {string} response.optionsName + * Name of a key to set. + * @param {string} response.optionValue + * Value to set. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.setDialogOption = function (ajax, response, status) { + var $dialog = $(response.selector); + if ($dialog.length) { + $dialog.dialog('option', response.optionName, response.optionValue); + } + }; + + /** + * Binds a listener on dialog creation to handle the cancel link. + * + * @param {jQuery.Event} e + * The event triggered. + * @param {Drupal.dialog~dialogDefinition} dialog + * The dialog instance. + * @param {jQuery} $element + * The jQuery collection of the dialog element. + * @param {object} [settings] + * Dialog settings. + */ + $(window).on('dialog:aftercreate', function (e, dialog, $element, settings) { + $element.on('click.dialog', '.dialog-cancel', function (e) { + dialog.close('cancel'); + e.preventDefault(); + e.stopPropagation(); + }); + }); + + /** + * Removes all 'dialog' listeners. + * + * @param {jQuery.Event} e + * The event triggered. + * @param {Drupal.dialog~dialogDefinition} dialog + * The dialog instance. + * @param {jQuery} $element + * jQuery collection of the dialog element. + */ + $(window).on('dialog:beforeclose', function (e, dialog, $element) { + $element.off('.dialog'); + }); + +})(jQuery, Drupal); diff --git a/core/misc/dialog/dialog.ajax.js b/core/misc/dialog/dialog.ajax.js index 3f1b0c2efd6f..0ab7e74d3c3c 100644 --- a/core/misc/dialog/dialog.ajax.js +++ b/core/misc/dialog/dialog.ajax.js @@ -1,74 +1,44 @@ /** - * @file - * Extends the Drupal AJAX functionality to integrate the dialog API. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/dialog/dialog.ajax.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Initialize dialogs for Ajax purposes. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the behaviors for dialog ajax functionality. - */ Drupal.behaviors.dialog = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $context = $(context); - // Provide a known 'drupal-modal' DOM element for Drupal-based modal - // dialogs. Non-modal dialogs are responsible for creating their own - // elements, since there can be multiple non-modal dialogs at a time. if (!$('#drupal-modal').length) { - // Add 'ui-front' jQuery UI class so jQuery UI widgets like autocomplete - // sit on top of dialogs. For more information see - // http://api.jqueryui.com/theming/stacking-elements/. $('<div id="drupal-modal" class="ui-front"/>').hide().appendTo('body'); } - // Special behaviors specific when attaching content within a dialog. - // These behaviors usually fire after a validation error inside a dialog. var $dialog = $context.closest('.ui-dialog-content'); if ($dialog.length) { - // Remove and replace the dialog buttons with those from the new form. if ($dialog.dialog('option', 'drupalAutoButtons')) { - // Trigger an event to detect/sync changes to buttons. $dialog.trigger('dialogButtonsChange'); } - // Force focus on the modal when the behavior is run. $dialog.dialog('widget').trigger('focus'); } var originalClose = settings.dialog.close; - // Overwrite the close method to remove the dialog on closing. + settings.dialog.close = function (event) { originalClose.apply(settings.dialog, arguments); $(event.target).remove(); }; }, - /** - * Scan a dialog for any primary buttons and move them to the button area. - * - * @param {jQuery} $dialog - * An jQuery object containing the element that is the dialog target. - * - * @return {Array} - * An array of buttons that need to be added to the button area. - */ - prepareDialogButtons: function ($dialog) { + prepareDialogButtons: function prepareDialogButtons($dialog) { var buttons = []; var $buttons = $dialog.find('.form-actions input[type=submit], .form-actions a.button'); $buttons.each(function () { - // Hidden form buttons need special attention. For browser consistency, - // the button needs to be "visible" in order to have the enter key fire - // the form submit event. So instead of a simple "hide" or - // "display: none", we set its dimensions to zero. - // See http://mattsnider.com/how-forms-submit-when-pressing-enter/ var $originalButton = $(this).css({ display: 'block', width: 0, @@ -80,13 +50,10 @@ buttons.push({ text: $originalButton.html() || $originalButton.attr('value'), class: $originalButton.attr('class'), - click: function (e) { - // If the original button is an anchor tag, triggering the "click" - // event will not simulate a click. Use the click method instead. + click: function click(e) { if ($originalButton.is('a')) { $originalButton[0].click(); - } - else { + } else { $originalButton.trigger('mousedown').trigger('mouseup').trigger('click'); e.preventDefault(); } @@ -97,80 +64,44 @@ } }; - /** - * Command to open a dialog. - * - * @param {Drupal.Ajax} ajax - * The Drupal Ajax object. - * @param {object} response - * Object holding the server response. - * @param {number} [status] - * The HTTP status code. - * - * @return {bool|undefined} - * Returns false if there was no selector property in the response object. - */ Drupal.AjaxCommands.prototype.openDialog = function (ajax, response, status) { if (!response.selector) { return false; } var $dialog = $(response.selector); if (!$dialog.length) { - // Create the element if needed. $dialog = $('<div id="' + response.selector.replace(/^#/, '') + '" class="ui-front"/>').appendTo('body'); } - // Set up the wrapper, if there isn't one. + if (!ajax.wrapper) { ajax.wrapper = $dialog.attr('id'); } - // Use the ajax.js insert command to populate the dialog contents. response.command = 'insert'; response.method = 'html'; ajax.commands.insert(ajax, response, status); - // Move the buttons to the jQuery UI dialog buttons area. if (!response.dialogOptions.buttons) { response.dialogOptions.drupalAutoButtons = true; response.dialogOptions.buttons = Drupal.behaviors.dialog.prepareDialogButtons($dialog); } - // Bind dialogButtonsChange. $dialog.on('dialogButtonsChange', function () { var buttons = Drupal.behaviors.dialog.prepareDialogButtons($dialog); $dialog.dialog('option', 'buttons', buttons); }); - // Open the dialog itself. response.dialogOptions = response.dialogOptions || {}; var dialog = Drupal.dialog($dialog.get(0), response.dialogOptions); if (response.dialogOptions.modal) { dialog.showModal(); - } - else { + } else { dialog.show(); } - // Add the standard Drupal class for buttons for style consistency. $dialog.parent().find('.ui-dialog-buttonset').addClass('form-actions'); }; - /** - * Command to close a dialog. - * - * If no selector is given, it defaults to trying to close the modal. - * - * @param {Drupal.Ajax} [ajax] - * The ajax object. - * @param {object} response - * Object holding the server response. - * @param {string} response.selector - * The selector of the dialog. - * @param {bool} response.persist - * Whether to persist the dialog element or not. - * @param {number} [status] - * The HTTP status code. - */ Drupal.AjaxCommands.prototype.closeDialog = function (ajax, response, status) { var $dialog = $(response.selector); if ($dialog.length) { @@ -180,28 +111,9 @@ } } - // Unbind dialogButtonsChange. $dialog.off('dialogButtonsChange'); }; - /** - * Command to set a dialog property. - * - * JQuery UI specific way of setting dialog options. - * - * @param {Drupal.Ajax} [ajax] - * The Drupal Ajax object. - * @param {object} response - * Object holding the server response. - * @param {string} response.selector - * Selector for the dialog element. - * @param {string} response.optionsName - * Name of a key to set. - * @param {string} response.optionValue - * Value to set. - * @param {number} [status] - * The HTTP status code. - */ Drupal.AjaxCommands.prototype.setDialogOption = function (ajax, response, status) { var $dialog = $(response.selector); if ($dialog.length) { @@ -209,18 +121,6 @@ } }; - /** - * Binds a listener on dialog creation to handle the cancel link. - * - * @param {jQuery.Event} e - * The event triggered. - * @param {Drupal.dialog~dialogDefinition} dialog - * The dialog instance. - * @param {jQuery} $element - * The jQuery collection of the dialog element. - * @param {object} [settings] - * Dialog settings. - */ $(window).on('dialog:aftercreate', function (e, dialog, $element, settings) { $element.on('click.dialog', '.dialog-cancel', function (e) { dialog.close('cancel'); @@ -229,18 +129,7 @@ }); }); - /** - * Removes all 'dialog' listeners. - * - * @param {jQuery.Event} e - * The event triggered. - * @param {Drupal.dialog~dialogDefinition} dialog - * The dialog instance. - * @param {jQuery} $element - * jQuery collection of the dialog element. - */ $(window).on('dialog:beforeclose', function (e, dialog, $element) { $element.off('.dialog'); }); - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/dialog/dialog.es6.js b/core/misc/dialog/dialog.es6.js new file mode 100644 index 000000000000..ea1e52c8dc9a --- /dev/null +++ b/core/misc/dialog/dialog.es6.js @@ -0,0 +1,100 @@ +/** + * @file + * Dialog API inspired by HTML5 dialog element. + * + * @see http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#the-dialog-element + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Default dialog options. + * + * @type {object} + * + * @prop {bool} [autoOpen=true] + * @prop {string} [dialogClass=''] + * @prop {string} [buttonClass='button'] + * @prop {string} [buttonPrimaryClass='button--primary'] + * @prop {function} close + */ + drupalSettings.dialog = { + autoOpen: true, + dialogClass: '', + // Drupal-specific extensions: see dialog.jquery-ui.js. + buttonClass: 'button', + buttonPrimaryClass: 'button--primary', + // When using this API directly (when generating dialogs on the client + // side), you may want to override this method and do + // `jQuery(event.target).remove()` as well, to remove the dialog on + // closing. + close: function (event) { + Drupal.dialog(event.target).close(); + Drupal.detachBehaviors(event.target, null, 'unload'); + } + }; + + /** + * @typedef {object} Drupal.dialog~dialogDefinition + * + * @prop {boolean} open + * Is the dialog open or not. + * @prop {*} returnValue + * Return value of the dialog. + * @prop {function} show + * Method to display the dialog on the page. + * @prop {function} showModal + * Method to display the dialog as a modal on the page. + * @prop {function} close + * Method to hide the dialog from the page. + */ + + /** + * Polyfill HTML5 dialog element with jQueryUI. + * + * @param {HTMLElement} element + * The element that holds the dialog. + * @param {object} options + * jQuery UI options to be passed to the dialog. + * + * @return {Drupal.dialog~dialogDefinition} + * The dialog instance. + */ + Drupal.dialog = function (element, options) { + var undef; + var $element = $(element); + var dialog = { + open: false, + returnValue: undef, + show: function () { + openDialog({modal: false}); + }, + showModal: function () { + openDialog({modal: true}); + }, + close: closeDialog + }; + + function openDialog(settings) { + settings = $.extend({}, drupalSettings.dialog, options, settings); + // Trigger a global event to allow scripts to bind events to the dialog. + $(window).trigger('dialog:beforecreate', [dialog, $element, settings]); + $element.dialog(settings); + dialog.open = true; + $(window).trigger('dialog:aftercreate', [dialog, $element, settings]); + } + + function closeDialog(value) { + $(window).trigger('dialog:beforeclose', [dialog, $element]); + $element.dialog('close'); + dialog.returnValue = value; + dialog.open = false; + $(window).trigger('dialog:afterclose', [dialog, $element]); + } + + return dialog; + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/misc/dialog/dialog.jquery-ui.es6.js b/core/misc/dialog/dialog.jquery-ui.es6.js new file mode 100644 index 000000000000..2526e30953b9 --- /dev/null +++ b/core/misc/dialog/dialog.jquery-ui.es6.js @@ -0,0 +1,36 @@ +/** + * @file + * Adds default classes to buttons for styling purposes. + */ + +(function ($) { + + 'use strict'; + + $.widget('ui.dialog', $.ui.dialog, { + options: { + buttonClass: 'button', + buttonPrimaryClass: 'button--primary' + }, + _createButtons: function () { + var opts = this.options; + var primaryIndex; + var $buttons; + var index; + var il = opts.buttons.length; + for (index = 0; index < il; index++) { + if (opts.buttons[index].primary && opts.buttons[index].primary === true) { + primaryIndex = index; + delete opts.buttons[index].primary; + break; + } + } + this._super(); + $buttons = this.uiButtonSet.children().addClass(opts.buttonClass); + if (typeof primaryIndex !== 'undefined') { + $buttons.eq(index).addClass(opts.buttonPrimaryClass); + } + } + }); + +})(jQuery); diff --git a/core/misc/dialog/dialog.jquery-ui.js b/core/misc/dialog/dialog.jquery-ui.js index 2526e30953b9..cfa777e0ae8c 100644 --- a/core/misc/dialog/dialog.jquery-ui.js +++ b/core/misc/dialog/dialog.jquery-ui.js @@ -1,7 +1,10 @@ /** - * @file - * Adds default classes to buttons for styling purposes. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/dialog/dialog.jquery-ui.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($) { @@ -12,7 +15,7 @@ buttonClass: 'button', buttonPrimaryClass: 'button--primary' }, - _createButtons: function () { + _createButtons: function _createButtons() { var opts = this.options; var primaryIndex; var $buttons; @@ -32,5 +35,4 @@ } } }); - -})(jQuery); +})(jQuery); \ No newline at end of file diff --git a/core/misc/dialog/dialog.js b/core/misc/dialog/dialog.js index ea1e52c8dc9a..3cd2b0b64b69 100644 --- a/core/misc/dialog/dialog.js +++ b/core/misc/dialog/dialog.js @@ -1,85 +1,46 @@ /** - * @file - * Dialog API inspired by HTML5 dialog element. - * - * @see http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#the-dialog-element - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/dialog/dialog.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Default dialog options. - * - * @type {object} - * - * @prop {bool} [autoOpen=true] - * @prop {string} [dialogClass=''] - * @prop {string} [buttonClass='button'] - * @prop {string} [buttonPrimaryClass='button--primary'] - * @prop {function} close - */ drupalSettings.dialog = { autoOpen: true, dialogClass: '', - // Drupal-specific extensions: see dialog.jquery-ui.js. + buttonClass: 'button', buttonPrimaryClass: 'button--primary', - // When using this API directly (when generating dialogs on the client - // side), you may want to override this method and do - // `jQuery(event.target).remove()` as well, to remove the dialog on - // closing. - close: function (event) { + + close: function close(event) { Drupal.dialog(event.target).close(); Drupal.detachBehaviors(event.target, null, 'unload'); } }; - /** - * @typedef {object} Drupal.dialog~dialogDefinition - * - * @prop {boolean} open - * Is the dialog open or not. - * @prop {*} returnValue - * Return value of the dialog. - * @prop {function} show - * Method to display the dialog on the page. - * @prop {function} showModal - * Method to display the dialog as a modal on the page. - * @prop {function} close - * Method to hide the dialog from the page. - */ - - /** - * Polyfill HTML5 dialog element with jQueryUI. - * - * @param {HTMLElement} element - * The element that holds the dialog. - * @param {object} options - * jQuery UI options to be passed to the dialog. - * - * @return {Drupal.dialog~dialogDefinition} - * The dialog instance. - */ Drupal.dialog = function (element, options) { var undef; var $element = $(element); var dialog = { open: false, returnValue: undef, - show: function () { - openDialog({modal: false}); + show: function show() { + openDialog({ modal: false }); }, - showModal: function () { - openDialog({modal: true}); + showModal: function showModal() { + openDialog({ modal: true }); }, close: closeDialog }; function openDialog(settings) { settings = $.extend({}, drupalSettings.dialog, options, settings); - // Trigger a global event to allow scripts to bind events to the dialog. + $(window).trigger('dialog:beforecreate', [dialog, $element, settings]); $element.dialog(settings); dialog.open = true; @@ -96,5 +57,4 @@ return dialog; }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/misc/dialog/dialog.position.es6.js b/core/misc/dialog/dialog.position.es6.js new file mode 100644 index 000000000000..e3c058f8440a --- /dev/null +++ b/core/misc/dialog/dialog.position.es6.js @@ -0,0 +1,112 @@ +/** + * @file + * Positioning extensions for dialogs. + */ + +/** + * Triggers when content inside a dialog changes. + * + * @event dialogContentResize + */ + +(function ($, Drupal, drupalSettings, debounce, displace) { + + 'use strict'; + + // autoResize option will turn off resizable and draggable. + drupalSettings.dialog = $.extend({autoResize: true, maxHeight: '95%'}, drupalSettings.dialog); + + /** + * Resets the current options for positioning. + * + * This is used as a window resize and scroll callback to reposition the + * jQuery UI dialog. Although not a built-in jQuery UI option, this can + * be disabled by setting autoResize: false in the options array when creating + * a new {@link Drupal.dialog}. + * + * @function Drupal.dialog~resetSize + * + * @param {jQuery.Event} event + * The event triggered. + * + * @fires event:dialogContentResize + */ + function resetSize(event) { + var positionOptions = ['width', 'height', 'minWidth', 'minHeight', 'maxHeight', 'maxWidth', 'position']; + var adjustedOptions = {}; + var windowHeight = $(window).height(); + var option; + var optionValue; + var adjustedValue; + for (var n = 0; n < positionOptions.length; n++) { + option = positionOptions[n]; + optionValue = event.data.settings[option]; + if (optionValue) { + // jQuery UI does not support percentages on heights, convert to pixels. + if (typeof optionValue === 'string' && /%$/.test(optionValue) && /height/i.test(option)) { + // Take offsets in account. + windowHeight -= displace.offsets.top + displace.offsets.bottom; + adjustedValue = parseInt(0.01 * parseInt(optionValue, 10) * windowHeight, 10); + // Don't force the dialog to be bigger vertically than needed. + if (option === 'height' && event.data.$element.parent().outerHeight() < adjustedValue) { + adjustedValue = 'auto'; + } + adjustedOptions[option] = adjustedValue; + } + } + } + // Offset the dialog center to be at the center of Drupal.displace.offsets. + if (!event.data.settings.modal) { + adjustedOptions = resetPosition(adjustedOptions); + } + event.data.$element + .dialog('option', adjustedOptions) + .trigger('dialogContentResize'); + } + + /** + * Position the dialog's center at the center of displace.offsets boundaries. + * + * @function Drupal.dialog~resetPosition + * + * @param {object} options + * Options object. + * + * @return {object} + * Altered options object. + */ + function resetPosition(options) { + var offsets = displace.offsets; + var left = offsets.left - offsets.right; + var top = offsets.top - offsets.bottom; + + var leftString = (left > 0 ? '+' : '-') + Math.abs(Math.round(left / 2)) + 'px'; + var topString = (top > 0 ? '+' : '-') + Math.abs(Math.round(top / 2)) + 'px'; + options.position = { + my: 'center' + (left !== 0 ? leftString : '') + ' center' + (top !== 0 ? topString : ''), + of: window + }; + return options; + } + + $(window).on({ + 'dialog:aftercreate': function (event, dialog, $element, settings) { + var autoResize = debounce(resetSize, 20); + var eventData = {settings: settings, $element: $element}; + if (settings.autoResize === true || settings.autoResize === 'true') { + $element + .dialog('option', {resizable: false, draggable: false}) + .dialog('widget').css('position', 'fixed'); + $(window) + .on('resize.dialogResize scroll.dialogResize', eventData, autoResize) + .trigger('resize.dialogResize'); + $(document).on('drupalViewportOffsetChange.dialogResize', eventData, autoResize); + } + }, + 'dialog:beforeclose': function (event, dialog, $element) { + $(window).off('.dialogResize'); + $(document).off('.dialogResize'); + } + }); + +})(jQuery, Drupal, drupalSettings, Drupal.debounce, Drupal.displace); diff --git a/core/misc/dialog/dialog.position.js b/core/misc/dialog/dialog.position.js index e3c058f8440a..fac104896e24 100644 --- a/core/misc/dialog/dialog.position.js +++ b/core/misc/dialog/dialog.position.js @@ -1,36 +1,17 @@ /** - * @file - * Positioning extensions for dialogs. - */ - -/** - * Triggers when content inside a dialog changes. - * - * @event dialogContentResize - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/dialog/dialog.position.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings, debounce, displace) { 'use strict'; - // autoResize option will turn off resizable and draggable. - drupalSettings.dialog = $.extend({autoResize: true, maxHeight: '95%'}, drupalSettings.dialog); + drupalSettings.dialog = $.extend({ autoResize: true, maxHeight: '95%' }, drupalSettings.dialog); - /** - * Resets the current options for positioning. - * - * This is used as a window resize and scroll callback to reposition the - * jQuery UI dialog. Although not a built-in jQuery UI option, this can - * be disabled by setting autoResize: false in the options array when creating - * a new {@link Drupal.dialog}. - * - * @function Drupal.dialog~resetSize - * - * @param {jQuery.Event} event - * The event triggered. - * - * @fires event:dialogContentResize - */ function resetSize(event) { var positionOptions = ['width', 'height', 'minWidth', 'minHeight', 'maxHeight', 'maxWidth', 'position']; var adjustedOptions = {}; @@ -42,12 +23,10 @@ option = positionOptions[n]; optionValue = event.data.settings[option]; if (optionValue) { - // jQuery UI does not support percentages on heights, convert to pixels. if (typeof optionValue === 'string' && /%$/.test(optionValue) && /height/i.test(option)) { - // Take offsets in account. windowHeight -= displace.offsets.top + displace.offsets.bottom; adjustedValue = parseInt(0.01 * parseInt(optionValue, 10) * windowHeight, 10); - // Don't force the dialog to be bigger vertically than needed. + if (option === 'height' && event.data.$element.parent().outerHeight() < adjustedValue) { adjustedValue = 'auto'; } @@ -55,26 +34,13 @@ } } } - // Offset the dialog center to be at the center of Drupal.displace.offsets. + if (!event.data.settings.modal) { adjustedOptions = resetPosition(adjustedOptions); } - event.data.$element - .dialog('option', adjustedOptions) - .trigger('dialogContentResize'); + event.data.$element.dialog('option', adjustedOptions).trigger('dialogContentResize'); } - /** - * Position the dialog's center at the center of displace.offsets boundaries. - * - * @function Drupal.dialog~resetPosition - * - * @param {object} options - * Options object. - * - * @return {object} - * Altered options object. - */ function resetPosition(options) { var offsets = displace.offsets; var left = offsets.left - offsets.right; @@ -90,23 +56,18 @@ } $(window).on({ - 'dialog:aftercreate': function (event, dialog, $element, settings) { + 'dialog:aftercreate': function dialogAftercreate(event, dialog, $element, settings) { var autoResize = debounce(resetSize, 20); - var eventData = {settings: settings, $element: $element}; + var eventData = { settings: settings, $element: $element }; if (settings.autoResize === true || settings.autoResize === 'true') { - $element - .dialog('option', {resizable: false, draggable: false}) - .dialog('widget').css('position', 'fixed'); - $(window) - .on('resize.dialogResize scroll.dialogResize', eventData, autoResize) - .trigger('resize.dialogResize'); + $element.dialog('option', { resizable: false, draggable: false }).dialog('widget').css('position', 'fixed'); + $(window).on('resize.dialogResize scroll.dialogResize', eventData, autoResize).trigger('resize.dialogResize'); $(document).on('drupalViewportOffsetChange.dialogResize', eventData, autoResize); } }, - 'dialog:beforeclose': function (event, dialog, $element) { + 'dialog:beforeclose': function dialogBeforeclose(event, dialog, $element) { $(window).off('.dialogResize'); $(document).off('.dialogResize'); } }); - -})(jQuery, Drupal, drupalSettings, Drupal.debounce, Drupal.displace); +})(jQuery, Drupal, drupalSettings, Drupal.debounce, Drupal.displace); \ No newline at end of file diff --git a/core/misc/displace.es6.js b/core/misc/displace.es6.js new file mode 100644 index 000000000000..3e89c563ecfd --- /dev/null +++ b/core/misc/displace.es6.js @@ -0,0 +1,222 @@ +/** + * @file + * Manages elements that can offset the size of the viewport. + * + * Measures and reports viewport offset dimensions from elements like the + * toolbar that can potentially displace the positioning of other elements. + */ + +/** + * @typedef {object} Drupal~displaceOffset + * + * @prop {number} top + * @prop {number} left + * @prop {number} right + * @prop {number} bottom + */ + +/** + * Triggers when layout of the page changes. + * + * This is used to position fixed element on the page during page resize and + * Toolbar toggling. + * + * @event drupalViewportOffsetChange + */ + +(function ($, Drupal, debounce) { + + 'use strict'; + + /** + * @name Drupal.displace.offsets + * + * @type {Drupal~displaceOffset} + */ + var offsets = { + top: 0, + right: 0, + bottom: 0, + left: 0 + }; + + /** + * Registers a resize handler on the window. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.drupalDisplace = { + attach: function () { + // Mark this behavior as processed on the first pass. + if (this.displaceProcessed) { + return; + } + this.displaceProcessed = true; + + $(window).on('resize.drupalDisplace', debounce(displace, 200)); + } + }; + + /** + * Informs listeners of the current offset dimensions. + * + * @function Drupal.displace + * + * @prop {Drupal~displaceOffset} offsets + * + * @param {bool} [broadcast] + * When true or undefined, causes the recalculated offsets values to be + * broadcast to listeners. + * + * @return {Drupal~displaceOffset} + * An object whose keys are the for sides an element -- top, right, bottom + * and left. The value of each key is the viewport displacement distance for + * that edge. + * + * @fires event:drupalViewportOffsetChange + */ + function displace(broadcast) { + offsets = Drupal.displace.offsets = calculateOffsets(); + if (typeof broadcast === 'undefined' || broadcast) { + $(document).trigger('drupalViewportOffsetChange', offsets); + } + return offsets; + } + + /** + * Determines the viewport offsets. + * + * @return {Drupal~displaceOffset} + * An object whose keys are the for sides an element -- top, right, bottom + * and left. The value of each key is the viewport displacement distance for + * that edge. + */ + function calculateOffsets() { + return { + top: calculateOffset('top'), + right: calculateOffset('right'), + bottom: calculateOffset('bottom'), + left: calculateOffset('left') + }; + } + + /** + * Gets a specific edge's offset. + * + * Any element with the attribute data-offset-{edge} e.g. data-offset-top will + * be considered in the viewport offset calculations. If the attribute has a + * numeric value, that value will be used. If no value is provided, one will + * be calculated using the element's dimensions and placement. + * + * @function Drupal.displace.calculateOffset + * + * @param {string} edge + * The name of the edge to calculate. Can be 'top', 'right', + * 'bottom' or 'left'. + * + * @return {number} + * The viewport displacement distance for the requested edge. + */ + function calculateOffset(edge) { + var edgeOffset = 0; + var displacingElements = document.querySelectorAll('[data-offset-' + edge + ']'); + var n = displacingElements.length; + for (var i = 0; i < n; i++) { + var el = displacingElements[i]; + // If the element is not visible, do consider its dimensions. + if (el.style.display === 'none') { + continue; + } + // If the offset data attribute contains a displacing value, use it. + var displacement = parseInt(el.getAttribute('data-offset-' + edge), 10); + // If the element's offset data attribute exits + // but is not a valid number then get the displacement + // dimensions directly from the element. + if (isNaN(displacement)) { + displacement = getRawOffset(el, edge); + } + // If the displacement value is larger than the current value for this + // edge, use the displacement value. + edgeOffset = Math.max(edgeOffset, displacement); + } + + return edgeOffset; + } + + /** + * Calculates displacement for element based on its dimensions and placement. + * + * @param {HTMLElement} el + * The jQuery element whose dimensions and placement will be measured. + * + * @param {string} edge + * The name of the edge of the viewport that the element is associated + * with. + * + * @return {number} + * The viewport displacement distance for the requested edge. + */ + function getRawOffset(el, edge) { + var $el = $(el); + var documentElement = document.documentElement; + var displacement = 0; + var horizontal = (edge === 'left' || edge === 'right'); + // Get the offset of the element itself. + var placement = $el.offset()[horizontal ? 'left' : 'top']; + // Subtract scroll distance from placement to get the distance + // to the edge of the viewport. + placement -= window['scroll' + (horizontal ? 'X' : 'Y')] || document.documentElement['scroll' + (horizontal ? 'Left' : 'Top')] || 0; + // Find the displacement value according to the edge. + switch (edge) { + // Left and top elements displace as a sum of their own offset value + // plus their size. + case 'top': + // Total displacement is the sum of the elements placement and size. + displacement = placement + $el.outerHeight(); + break; + + case 'left': + // Total displacement is the sum of the elements placement and size. + displacement = placement + $el.outerWidth(); + break; + + // Right and bottom elements displace according to their left and + // top offset. Their size isn't important. + case 'bottom': + displacement = documentElement.clientHeight - placement; + break; + + case 'right': + displacement = documentElement.clientWidth - placement; + break; + + default: + displacement = 0; + } + return displacement; + } + + /** + * Assign the displace function to a property of the Drupal global object. + * + * @ignore + */ + Drupal.displace = displace; + $.extend(Drupal.displace, { + + /** + * Expose offsets to other scripts to avoid having to recalculate offsets. + * + * @ignore + */ + offsets: offsets, + + /** + * Expose method to compute a single edge offsets. + * + * @ignore + */ + calculateOffset: calculateOffset + }); + +})(jQuery, Drupal, Drupal.debounce); diff --git a/core/misc/displace.js b/core/misc/displace.js index 3e89c563ecfd..da3f510dcbb4 100644 --- a/core/misc/displace.js +++ b/core/misc/displace.js @@ -1,38 +1,15 @@ /** - * @file - * Manages elements that can offset the size of the viewport. - * - * Measures and reports viewport offset dimensions from elements like the - * toolbar that can potentially displace the positioning of other elements. - */ - -/** - * @typedef {object} Drupal~displaceOffset - * - * @prop {number} top - * @prop {number} left - * @prop {number} right - * @prop {number} bottom - */ - -/** - * Triggers when layout of the page changes. - * - * This is used to position fixed element on the page during page resize and - * Toolbar toggling. - * - * @event drupalViewportOffsetChange - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/displace.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, debounce) { 'use strict'; - /** - * @name Drupal.displace.offsets - * - * @type {Drupal~displaceOffset} - */ var offsets = { top: 0, right: 0, @@ -40,14 +17,8 @@ left: 0 }; - /** - * Registers a resize handler on the window. - * - * @type {Drupal~behavior} - */ Drupal.behaviors.drupalDisplace = { - attach: function () { - // Mark this behavior as processed on the first pass. + attach: function attach() { if (this.displaceProcessed) { return; } @@ -57,24 +28,6 @@ } }; - /** - * Informs listeners of the current offset dimensions. - * - * @function Drupal.displace - * - * @prop {Drupal~displaceOffset} offsets - * - * @param {bool} [broadcast] - * When true or undefined, causes the recalculated offsets values to be - * broadcast to listeners. - * - * @return {Drupal~displaceOffset} - * An object whose keys are the for sides an element -- top, right, bottom - * and left. The value of each key is the viewport displacement distance for - * that edge. - * - * @fires event:drupalViewportOffsetChange - */ function displace(broadcast) { offsets = Drupal.displace.offsets = calculateOffsets(); if (typeof broadcast === 'undefined' || broadcast) { @@ -83,14 +36,6 @@ return offsets; } - /** - * Determines the viewport offsets. - * - * @return {Drupal~displaceOffset} - * An object whose keys are the for sides an element -- top, right, bottom - * and left. The value of each key is the viewport displacement distance for - * that edge. - */ function calculateOffsets() { return { top: calculateOffset('top'), @@ -100,88 +45,48 @@ }; } - /** - * Gets a specific edge's offset. - * - * Any element with the attribute data-offset-{edge} e.g. data-offset-top will - * be considered in the viewport offset calculations. If the attribute has a - * numeric value, that value will be used. If no value is provided, one will - * be calculated using the element's dimensions and placement. - * - * @function Drupal.displace.calculateOffset - * - * @param {string} edge - * The name of the edge to calculate. Can be 'top', 'right', - * 'bottom' or 'left'. - * - * @return {number} - * The viewport displacement distance for the requested edge. - */ function calculateOffset(edge) { var edgeOffset = 0; var displacingElements = document.querySelectorAll('[data-offset-' + edge + ']'); var n = displacingElements.length; for (var i = 0; i < n; i++) { var el = displacingElements[i]; - // If the element is not visible, do consider its dimensions. + if (el.style.display === 'none') { continue; } - // If the offset data attribute contains a displacing value, use it. + var displacement = parseInt(el.getAttribute('data-offset-' + edge), 10); - // If the element's offset data attribute exits - // but is not a valid number then get the displacement - // dimensions directly from the element. + if (isNaN(displacement)) { displacement = getRawOffset(el, edge); } - // If the displacement value is larger than the current value for this - // edge, use the displacement value. + edgeOffset = Math.max(edgeOffset, displacement); } return edgeOffset; } - /** - * Calculates displacement for element based on its dimensions and placement. - * - * @param {HTMLElement} el - * The jQuery element whose dimensions and placement will be measured. - * - * @param {string} edge - * The name of the edge of the viewport that the element is associated - * with. - * - * @return {number} - * The viewport displacement distance for the requested edge. - */ function getRawOffset(el, edge) { var $el = $(el); var documentElement = document.documentElement; var displacement = 0; - var horizontal = (edge === 'left' || edge === 'right'); - // Get the offset of the element itself. + var horizontal = edge === 'left' || edge === 'right'; + var placement = $el.offset()[horizontal ? 'left' : 'top']; - // Subtract scroll distance from placement to get the distance - // to the edge of the viewport. + placement -= window['scroll' + (horizontal ? 'X' : 'Y')] || document.documentElement['scroll' + (horizontal ? 'Left' : 'Top')] || 0; - // Find the displacement value according to the edge. + switch (edge) { - // Left and top elements displace as a sum of their own offset value - // plus their size. case 'top': - // Total displacement is the sum of the elements placement and size. displacement = placement + $el.outerHeight(); break; case 'left': - // Total displacement is the sum of the elements placement and size. displacement = placement + $el.outerWidth(); break; - // Right and bottom elements displace according to their left and - // top offset. Their size isn't important. case 'bottom': displacement = documentElement.clientHeight - placement; break; @@ -196,27 +101,10 @@ return displacement; } - /** - * Assign the displace function to a property of the Drupal global object. - * - * @ignore - */ Drupal.displace = displace; $.extend(Drupal.displace, { - - /** - * Expose offsets to other scripts to avoid having to recalculate offsets. - * - * @ignore - */ offsets: offsets, - /** - * Expose method to compute a single edge offsets. - * - * @ignore - */ calculateOffset: calculateOffset }); - -})(jQuery, Drupal, Drupal.debounce); +})(jQuery, Drupal, Drupal.debounce); \ No newline at end of file diff --git a/core/misc/dropbutton/dropbutton.es6.js b/core/misc/dropbutton/dropbutton.es6.js new file mode 100644 index 000000000000..04f9491ad739 --- /dev/null +++ b/core/misc/dropbutton/dropbutton.es6.js @@ -0,0 +1,233 @@ +/** + * @file + * Dropbutton feature. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Process elements with the .dropbutton class on page load. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches dropButton behaviors. + */ + Drupal.behaviors.dropButton = { + attach: function (context, settings) { + var $dropbuttons = $(context).find('.dropbutton-wrapper').once('dropbutton'); + if ($dropbuttons.length) { + // Adds the delegated handler that will toggle dropdowns on click. + var $body = $('body').once('dropbutton-click'); + if ($body.length) { + $body.on('click', '.dropbutton-toggle', dropbuttonClickHandler); + } + // Initialize all buttons. + var il = $dropbuttons.length; + for (var i = 0; i < il; i++) { + DropButton.dropbuttons.push(new DropButton($dropbuttons[i], settings.dropbutton)); + } + } + } + }; + + /** + * Delegated callback for opening and closing dropbutton secondary actions. + * + * @function Drupal.DropButton~dropbuttonClickHandler + * + * @param {jQuery.Event} e + * The event triggered. + */ + function dropbuttonClickHandler(e) { + e.preventDefault(); + $(e.target).closest('.dropbutton-wrapper').toggleClass('open'); + } + + /** + * A DropButton presents an HTML list as a button with a primary action. + * + * All secondary actions beyond the first in the list are presented in a + * dropdown list accessible through a toggle arrow associated with the button. + * + * @constructor Drupal.DropButton + * + * @param {HTMLElement} dropbutton + * A DOM element. + * @param {object} settings + * A list of options including: + * @param {string} settings.title + * The text inside the toggle link element. This text is hidden + * from visual UAs. + */ + function DropButton(dropbutton, settings) { + // Merge defaults with settings. + var options = $.extend({title: Drupal.t('List additional actions')}, settings); + var $dropbutton = $(dropbutton); + + /** + * @type {jQuery} + */ + this.$dropbutton = $dropbutton; + + /** + * @type {jQuery} + */ + this.$list = $dropbutton.find('.dropbutton'); + + /** + * Find actions and mark them. + * + * @type {jQuery} + */ + this.$actions = this.$list.find('li').addClass('dropbutton-action'); + + // Add the special dropdown only if there are hidden actions. + if (this.$actions.length > 1) { + // Identify the first element of the collection. + var $primary = this.$actions.slice(0, 1); + // Identify the secondary actions. + var $secondary = this.$actions.slice(1); + $secondary.addClass('secondary-action'); + // Add toggle link. + $primary.after(Drupal.theme('dropbuttonToggle', options)); + // Bind mouse events. + this.$dropbutton + .addClass('dropbutton-multiple') + .on({ + + /** + * Adds a timeout to close the dropdown on mouseleave. + * + * @ignore + */ + 'mouseleave.dropbutton': $.proxy(this.hoverOut, this), + + /** + * Clears timeout when mouseout of the dropdown. + * + * @ignore + */ + 'mouseenter.dropbutton': $.proxy(this.hoverIn, this), + + /** + * Similar to mouseleave/mouseenter, but for keyboard navigation. + * + * @ignore + */ + 'focusout.dropbutton': $.proxy(this.focusOut, this), + + /** + * @ignore + */ + 'focusin.dropbutton': $.proxy(this.focusIn, this) + }); + } + else { + this.$dropbutton.addClass('dropbutton-single'); + } + } + + /** + * Extend the DropButton constructor. + */ + $.extend(DropButton, /** @lends Drupal.DropButton */{ + /** + * Store all processed DropButtons. + * + * @type {Array.<Drupal.DropButton>} + */ + dropbuttons: [] + }); + + /** + * Extend the DropButton prototype. + */ + $.extend(DropButton.prototype, /** @lends Drupal.DropButton# */{ + + /** + * Toggle the dropbutton open and closed. + * + * @param {bool} [show] + * Force the dropbutton to open by passing true or to close by + * passing false. + */ + toggle: function (show) { + var isBool = typeof show === 'boolean'; + show = isBool ? show : !this.$dropbutton.hasClass('open'); + this.$dropbutton.toggleClass('open', show); + }, + + /** + * @method + */ + hoverIn: function () { + // Clear any previous timer we were using. + if (this.timerID) { + window.clearTimeout(this.timerID); + } + }, + + /** + * @method + */ + hoverOut: function () { + // Wait half a second before closing. + this.timerID = window.setTimeout($.proxy(this, 'close'), 500); + }, + + /** + * @method + */ + open: function () { + this.toggle(true); + }, + + /** + * @method + */ + close: function () { + this.toggle(false); + }, + + /** + * @param {jQuery.Event} e + * The event triggered. + */ + focusOut: function (e) { + this.hoverOut.call(this, e); + }, + + /** + * @param {jQuery.Event} e + * The event triggered. + */ + focusIn: function (e) { + this.hoverIn.call(this, e); + } + }); + + $.extend(Drupal.theme, /** @lends Drupal.theme */{ + + /** + * A toggle is an interactive element often bound to a click handler. + * + * @param {object} options + * Options object. + * @param {string} [options.title] + * The HTML anchor title attribute and text for the inner span element. + * + * @return {string} + * A string representing a DOM fragment. + */ + dropbuttonToggle: function (options) { + return '<li class="dropbutton-toggle"><button type="button"><span class="dropbutton-arrow"><span class="visually-hidden">' + options.title + '</span></span></button></li>'; + } + }); + + // Expose constructor in the public space. + Drupal.DropButton = DropButton; + +})(jQuery, Drupal); diff --git a/core/misc/dropbutton/dropbutton.js b/core/misc/dropbutton/dropbutton.js index 04f9491ad739..43ccda4552b7 100644 --- a/core/misc/dropbutton/dropbutton.js +++ b/core/misc/dropbutton/dropbutton.js @@ -1,30 +1,24 @@ /** - * @file - * Dropbutton feature. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/dropbutton/dropbutton.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Process elements with the .dropbutton class on page load. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches dropButton behaviors. - */ Drupal.behaviors.dropButton = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $dropbuttons = $(context).find('.dropbutton-wrapper').once('dropbutton'); if ($dropbuttons.length) { - // Adds the delegated handler that will toggle dropdowns on click. var $body = $('body').once('dropbutton-click'); if ($body.length) { $body.on('click', '.dropbutton-toggle', dropbuttonClickHandler); } - // Initialize all buttons. + var il = $dropbuttons.length; for (var i = 0; i < il; i++) { DropButton.dropbuttons.push(new DropButton($dropbuttons[i], settings.dropbutton)); @@ -33,201 +27,86 @@ } }; - /** - * Delegated callback for opening and closing dropbutton secondary actions. - * - * @function Drupal.DropButton~dropbuttonClickHandler - * - * @param {jQuery.Event} e - * The event triggered. - */ function dropbuttonClickHandler(e) { e.preventDefault(); $(e.target).closest('.dropbutton-wrapper').toggleClass('open'); } - /** - * A DropButton presents an HTML list as a button with a primary action. - * - * All secondary actions beyond the first in the list are presented in a - * dropdown list accessible through a toggle arrow associated with the button. - * - * @constructor Drupal.DropButton - * - * @param {HTMLElement} dropbutton - * A DOM element. - * @param {object} settings - * A list of options including: - * @param {string} settings.title - * The text inside the toggle link element. This text is hidden - * from visual UAs. - */ function DropButton(dropbutton, settings) { - // Merge defaults with settings. - var options = $.extend({title: Drupal.t('List additional actions')}, settings); + var options = $.extend({ title: Drupal.t('List additional actions') }, settings); var $dropbutton = $(dropbutton); - /** - * @type {jQuery} - */ this.$dropbutton = $dropbutton; - /** - * @type {jQuery} - */ this.$list = $dropbutton.find('.dropbutton'); - /** - * Find actions and mark them. - * - * @type {jQuery} - */ this.$actions = this.$list.find('li').addClass('dropbutton-action'); - // Add the special dropdown only if there are hidden actions. if (this.$actions.length > 1) { - // Identify the first element of the collection. var $primary = this.$actions.slice(0, 1); - // Identify the secondary actions. + var $secondary = this.$actions.slice(1); $secondary.addClass('secondary-action'); - // Add toggle link. + $primary.after(Drupal.theme('dropbuttonToggle', options)); - // Bind mouse events. - this.$dropbutton - .addClass('dropbutton-multiple') - .on({ - - /** - * Adds a timeout to close the dropdown on mouseleave. - * - * @ignore - */ - 'mouseleave.dropbutton': $.proxy(this.hoverOut, this), - - /** - * Clears timeout when mouseout of the dropdown. - * - * @ignore - */ - 'mouseenter.dropbutton': $.proxy(this.hoverIn, this), - - /** - * Similar to mouseleave/mouseenter, but for keyboard navigation. - * - * @ignore - */ - 'focusout.dropbutton': $.proxy(this.focusOut, this), - - /** - * @ignore - */ - 'focusin.dropbutton': $.proxy(this.focusIn, this) - }); - } - else { + + this.$dropbutton.addClass('dropbutton-multiple').on({ + 'mouseleave.dropbutton': $.proxy(this.hoverOut, this), + + 'mouseenter.dropbutton': $.proxy(this.hoverIn, this), + + 'focusout.dropbutton': $.proxy(this.focusOut, this), + + 'focusin.dropbutton': $.proxy(this.focusIn, this) + }); + } else { this.$dropbutton.addClass('dropbutton-single'); } } - /** - * Extend the DropButton constructor. - */ - $.extend(DropButton, /** @lends Drupal.DropButton */{ - /** - * Store all processed DropButtons. - * - * @type {Array.<Drupal.DropButton>} - */ + $.extend(DropButton, { dropbuttons: [] }); - /** - * Extend the DropButton prototype. - */ - $.extend(DropButton.prototype, /** @lends Drupal.DropButton# */{ - - /** - * Toggle the dropbutton open and closed. - * - * @param {bool} [show] - * Force the dropbutton to open by passing true or to close by - * passing false. - */ - toggle: function (show) { + $.extend(DropButton.prototype, { + toggle: function toggle(show) { var isBool = typeof show === 'boolean'; show = isBool ? show : !this.$dropbutton.hasClass('open'); this.$dropbutton.toggleClass('open', show); }, - /** - * @method - */ - hoverIn: function () { - // Clear any previous timer we were using. + hoverIn: function hoverIn() { if (this.timerID) { window.clearTimeout(this.timerID); } }, - /** - * @method - */ - hoverOut: function () { - // Wait half a second before closing. + hoverOut: function hoverOut() { this.timerID = window.setTimeout($.proxy(this, 'close'), 500); }, - /** - * @method - */ - open: function () { + open: function open() { this.toggle(true); }, - /** - * @method - */ - close: function () { + close: function close() { this.toggle(false); }, - /** - * @param {jQuery.Event} e - * The event triggered. - */ - focusOut: function (e) { + focusOut: function focusOut(e) { this.hoverOut.call(this, e); }, - /** - * @param {jQuery.Event} e - * The event triggered. - */ - focusIn: function (e) { + focusIn: function focusIn(e) { this.hoverIn.call(this, e); } }); - $.extend(Drupal.theme, /** @lends Drupal.theme */{ - - /** - * A toggle is an interactive element often bound to a click handler. - * - * @param {object} options - * Options object. - * @param {string} [options.title] - * The HTML anchor title attribute and text for the inner span element. - * - * @return {string} - * A string representing a DOM fragment. - */ - dropbuttonToggle: function (options) { + $.extend(Drupal.theme, { + dropbuttonToggle: function dropbuttonToggle(options) { return '<li class="dropbutton-toggle"><button type="button"><span class="dropbutton-arrow"><span class="visually-hidden">' + options.title + '</span></span></button></li>'; } }); - // Expose constructor in the public space. Drupal.DropButton = DropButton; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/drupal.es6.js b/core/misc/drupal.es6.js new file mode 100644 index 000000000000..d509795b864e --- /dev/null +++ b/core/misc/drupal.es6.js @@ -0,0 +1,583 @@ +/** + * @file + * Defines the Drupal JavaScript API. + */ + +/** + * A jQuery object, typically the return value from a `$(selector)` call. + * + * Holds an HTMLElement or a collection of HTMLElements. + * + * @typedef {object} jQuery + * + * @prop {number} length=0 + * Number of elements contained in the jQuery object. + */ + +/** + * Variable generated by Drupal that holds all translated strings from PHP. + * + * Content of this variable is automatically created by Drupal when using the + * Interface Translation module. It holds the translation of strings used on + * the page. + * + * This variable is used to pass data from the backend to the frontend. Data + * contained in `drupalSettings` is used during behavior initialization. + * + * @global + * + * @var {object} drupalTranslations + */ + +/** + * Global Drupal object. + * + * All Drupal JavaScript APIs are contained in this namespace. + * + * @global + * + * @namespace + */ +window.Drupal = {behaviors: {}, locale: {}}; + +// JavaScript should be made compatible with libraries other than jQuery by +// wrapping it in an anonymous closure. +(function (Drupal, drupalSettings, drupalTranslations) { + + 'use strict'; + + /** + * Helper to rethrow errors asynchronously. + * + * This way Errors bubbles up outside of the original callstack, making it + * easier to debug errors in the browser. + * + * @param {Error|string} error + * The error to be thrown. + */ + Drupal.throwError = function (error) { + setTimeout(function () { throw error; }, 0); + }; + + /** + * Custom error thrown after attach/detach if one or more behaviors failed. + * Initializes the JavaScript behaviors for page loads and Ajax requests. + * + * @callback Drupal~behaviorAttach + * + * @param {HTMLDocument|HTMLElement} context + * An element to detach behaviors from. + * @param {?object} settings + * An object containing settings for the current context. It is rarely used. + * + * @see Drupal.attachBehaviors + */ + + /** + * Reverts and cleans up JavaScript behavior initialization. + * + * @callback Drupal~behaviorDetach + * + * @param {HTMLDocument|HTMLElement} context + * An element to attach behaviors to. + * @param {object} settings + * An object containing settings for the current context. + * @param {string} trigger + * One of `'unload'`, `'move'`, or `'serialize'`. + * + * @see Drupal.detachBehaviors + */ + + /** + * @typedef {object} Drupal~behavior + * + * @prop {Drupal~behaviorAttach} attach + * Function run on page load and after an Ajax call. + * @prop {Drupal~behaviorDetach} detach + * Function run when content is serialized or removed from the page. + */ + + /** + * Holds all initialization methods. + * + * @namespace Drupal.behaviors + * + * @type {Object.<string, Drupal~behavior>} + */ + + /** + * Defines a behavior to be run during attach and detach phases. + * + * Attaches all registered behaviors to a page element. + * + * Behaviors are event-triggered actions that attach to page elements, + * enhancing default non-JavaScript UIs. Behaviors are registered in the + * {@link Drupal.behaviors} object using the method 'attach' and optionally + * also 'detach'. + * + * {@link Drupal.attachBehaviors} is added below to the `jQuery.ready` event + * and therefore runs on initial page load. Developers implementing Ajax in + * their solutions should also call this function after new page content has + * been loaded, feeding in an element to be processed, in order to attach all + * behaviors to the new content. + * + * Behaviors should use `var elements = + * $(context).find(selector).once('behavior-name');` to ensure the behavior is + * attached only once to a given element. (Doing so enables the reprocessing + * of given elements, which may be needed on occasion despite the ability to + * limit behavior attachment to a particular element.) + * + * @example + * Drupal.behaviors.behaviorName = { + * attach: function (context, settings) { + * // ... + * }, + * detach: function (context, settings, trigger) { + * // ... + * } + * }; + * + * @param {HTMLDocument|HTMLElement} [context=document] + * An element to attach behaviors to. + * @param {object} [settings=drupalSettings] + * An object containing settings for the current context. If none is given, + * the global {@link drupalSettings} object is used. + * + * @see Drupal~behaviorAttach + * @see Drupal.detachBehaviors + * + * @throws {Drupal~DrupalBehaviorError} + */ + Drupal.attachBehaviors = function (context, settings) { + context = context || document; + settings = settings || drupalSettings; + var behaviors = Drupal.behaviors; + // Execute all of them. + for (var i in behaviors) { + if (behaviors.hasOwnProperty(i) && typeof behaviors[i].attach === 'function') { + // Don't stop the execution of behaviors in case of an error. + try { + behaviors[i].attach(context, settings); + } + catch (e) { + Drupal.throwError(e); + } + } + } + }; + + /** + * Detaches registered behaviors from a page element. + * + * Developers implementing Ajax in their solutions should call this function + * before page content is about to be removed, feeding in an element to be + * processed, in order to allow special behaviors to detach from the content. + * + * Such implementations should use `.findOnce()` and `.removeOnce()` to find + * elements with their corresponding `Drupal.behaviors.behaviorName.attach` + * implementation, i.e. `.removeOnce('behaviorName')`, to ensure the behavior + * is detached only from previously processed elements. + * + * @param {HTMLDocument|HTMLElement} [context=document] + * An element to detach behaviors from. + * @param {object} [settings=drupalSettings] + * An object containing settings for the current context. If none given, + * the global {@link drupalSettings} object is used. + * @param {string} [trigger='unload'] + * A string containing what's causing the behaviors to be detached. The + * possible triggers are: + * - `'unload'`: The context element is being removed from the DOM. + * - `'move'`: The element is about to be moved within the DOM (for example, + * during a tabledrag row swap). After the move is completed, + * {@link Drupal.attachBehaviors} is called, so that the behavior can undo + * whatever it did in response to the move. Many behaviors won't need to + * do anything simply in response to the element being moved, but because + * IFRAME elements reload their "src" when being moved within the DOM, + * behaviors bound to IFRAME elements (like WYSIWYG editors) may need to + * take some action. + * - `'serialize'`: When an Ajax form is submitted, this is called with the + * form as the context. This provides every behavior within the form an + * opportunity to ensure that the field elements have correct content + * in them before the form is serialized. The canonical use-case is so + * that WYSIWYG editors can update the hidden textarea to which they are + * bound. + * + * @throws {Drupal~DrupalBehaviorError} + * + * @see Drupal~behaviorDetach + * @see Drupal.attachBehaviors + */ + Drupal.detachBehaviors = function (context, settings, trigger) { + context = context || document; + settings = settings || drupalSettings; + trigger = trigger || 'unload'; + var behaviors = Drupal.behaviors; + // Execute all of them. + for (var i in behaviors) { + if (behaviors.hasOwnProperty(i) && typeof behaviors[i].detach === 'function') { + // Don't stop the execution of behaviors in case of an error. + try { + behaviors[i].detach(context, settings, trigger); + } + catch (e) { + Drupal.throwError(e); + } + } + } + }; + + /** + * Encodes special characters in a plain-text string for display as HTML. + * + * @param {string} str + * The string to be encoded. + * + * @return {string} + * The encoded string. + * + * @ingroup sanitization + */ + Drupal.checkPlain = function (str) { + str = str.toString() + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>'); + return str; + }; + + /** + * Replaces placeholders with sanitized values in a string. + * + * @param {string} str + * A string with placeholders. + * @param {object} args + * An object of replacements pairs to make. Incidences of any key in this + * array are replaced with the corresponding value. Based on the first + * character of the key, the value is escaped and/or themed: + * - `'!variable'`: inserted as is. + * - `'@variable'`: escape plain text to HTML ({@link Drupal.checkPlain}). + * - `'%variable'`: escape text and theme as a placeholder for user- + * submitted content ({@link Drupal.checkPlain} + + * `{@link Drupal.theme}('placeholder')`). + * + * @return {string} + * The formatted string. + * + * @see Drupal.t + */ + Drupal.formatString = function (str, args) { + // Keep args intact. + var processedArgs = {}; + // Transform arguments before inserting them. + for (var key in args) { + if (args.hasOwnProperty(key)) { + switch (key.charAt(0)) { + // Escaped only. + case '@': + processedArgs[key] = Drupal.checkPlain(args[key]); + break; + + // Pass-through. + case '!': + processedArgs[key] = args[key]; + break; + + // Escaped and placeholder. + default: + processedArgs[key] = Drupal.theme('placeholder', args[key]); + break; + } + } + } + + return Drupal.stringReplace(str, processedArgs, null); + }; + + /** + * Replaces substring. + * + * The longest keys will be tried first. Once a substring has been replaced, + * its new value will not be searched again. + * + * @param {string} str + * A string with placeholders. + * @param {object} args + * Key-value pairs. + * @param {Array|null} keys + * Array of keys from `args`. Internal use only. + * + * @return {string} + * The replaced string. + */ + Drupal.stringReplace = function (str, args, keys) { + if (str.length === 0) { + return str; + } + + // If the array of keys is not passed then collect the keys from the args. + if (!Array.isArray(keys)) { + keys = []; + for (var k in args) { + if (args.hasOwnProperty(k)) { + keys.push(k); + } + } + + // Order the keys by the character length. The shortest one is the first. + keys.sort(function (a, b) { return a.length - b.length; }); + } + + if (keys.length === 0) { + return str; + } + + // Take next longest one from the end. + var key = keys.pop(); + var fragments = str.split(key); + + if (keys.length) { + for (var i = 0; i < fragments.length; i++) { + // Process each fragment with a copy of remaining keys. + fragments[i] = Drupal.stringReplace(fragments[i], args, keys.slice(0)); + } + } + + return fragments.join(args[key]); + }; + + /** + * Translates strings to the page language, or a given language. + * + * See the documentation of the server-side t() function for further details. + * + * @param {string} str + * A string containing the English text to translate. + * @param {Object.<string, string>} [args] + * An object of replacements pairs to make after translation. Incidences + * of any key in this array are replaced with the corresponding value. + * See {@link Drupal.formatString}. + * @param {object} [options] + * Additional options for translation. + * @param {string} [options.context=''] + * The context the source string belongs to. + * + * @return {string} + * The formatted string. + * The translated string. + */ + Drupal.t = function (str, args, options) { + options = options || {}; + options.context = options.context || ''; + + // Fetch the localized version of the string. + if (typeof drupalTranslations !== 'undefined' && drupalTranslations.strings && drupalTranslations.strings[options.context] && drupalTranslations.strings[options.context][str]) { + str = drupalTranslations.strings[options.context][str]; + } + + if (args) { + str = Drupal.formatString(str, args); + } + return str; + }; + + /** + * Returns the URL to a Drupal page. + * + * @param {string} path + * Drupal path to transform to URL. + * + * @return {string} + * The full URL. + */ + Drupal.url = function (path) { + return drupalSettings.path.baseUrl + drupalSettings.path.pathPrefix + path; + }; + + /** + * Returns the passed in URL as an absolute URL. + * + * @param {string} url + * The URL string to be normalized to an absolute URL. + * + * @return {string} + * The normalized, absolute URL. + * + * @see https://github.com/angular/angular.js/blob/v1.4.4/src/ng/urlUtils.js + * @see https://grack.com/blog/2009/11/17/absolutizing-url-in-javascript + * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L53 + */ + Drupal.url.toAbsolute = function (url) { + var urlParsingNode = document.createElement('a'); + + // Decode the URL first; this is required by IE <= 6. Decoding non-UTF-8 + // strings may throw an exception. + try { + url = decodeURIComponent(url); + } + catch (e) { + // Empty. + } + + urlParsingNode.setAttribute('href', url); + + // IE <= 7 normalizes the URL when assigned to the anchor node similar to + // the other browsers. + return urlParsingNode.cloneNode(false).href; + }; + + /** + * Returns true if the URL is within Drupal's base path. + * + * @param {string} url + * The URL string to be tested. + * + * @return {bool} + * `true` if local. + * + * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L58 + */ + Drupal.url.isLocal = function (url) { + // Always use browser-derived absolute URLs in the comparison, to avoid + // attempts to break out of the base path using directory traversal. + var absoluteUrl = Drupal.url.toAbsolute(url); + var protocol = location.protocol; + + // Consider URLs that match this site's base URL but use HTTPS instead of HTTP + // as local as well. + if (protocol === 'http:' && absoluteUrl.indexOf('https:') === 0) { + protocol = 'https:'; + } + var baseUrl = protocol + '//' + location.host + drupalSettings.path.baseUrl.slice(0, -1); + + // Decoding non-UTF-8 strings may throw an exception. + try { + absoluteUrl = decodeURIComponent(absoluteUrl); + } + catch (e) { + // Empty. + } + try { + baseUrl = decodeURIComponent(baseUrl); + } + catch (e) { + // Empty. + } + + // The given URL matches the site's base URL, or has a path under the site's + // base URL. + return absoluteUrl === baseUrl || absoluteUrl.indexOf(baseUrl + '/') === 0; + }; + + /** + * Formats a string containing a count of items. + * + * This function ensures that the string is pluralized correctly. Since + * {@link Drupal.t} is called by this function, make sure not to pass + * already-localized strings to it. + * + * See the documentation of the server-side + * \Drupal\Core\StringTranslation\TranslationInterface::formatPlural() + * function for more details. + * + * @param {number} count + * The item count to display. + * @param {string} singular + * The string for the singular case. Please make sure it is clear this is + * singular, to ease translation (e.g. use "1 new comment" instead of "1 + * new"). Do not use @count in the singular string. + * @param {string} plural + * The string for the plural case. Please make sure it is clear this is + * plural, to ease translation. Use @count in place of the item count, as in + * "@count new comments". + * @param {object} [args] + * An object of replacements pairs to make after translation. Incidences + * of any key in this array are replaced with the corresponding value. + * See {@link Drupal.formatString}. + * Note that you do not need to include @count in this array. + * This replacement is done automatically for the plural case. + * @param {object} [options] + * The options to pass to the {@link Drupal.t} function. + * + * @return {string} + * A translated string. + */ + Drupal.formatPlural = function (count, singular, plural, args, options) { + args = args || {}; + args['@count'] = count; + + var pluralDelimiter = drupalSettings.pluralDelimiter; + var translations = Drupal.t(singular + pluralDelimiter + plural, args, options).split(pluralDelimiter); + var index = 0; + + // Determine the index of the plural form. + if (typeof drupalTranslations !== 'undefined' && drupalTranslations.pluralFormula) { + index = count in drupalTranslations.pluralFormula ? drupalTranslations.pluralFormula[count] : drupalTranslations.pluralFormula['default']; + } + else if (args['@count'] !== 1) { + index = 1; + } + + return translations[index]; + }; + + /** + * Encodes a Drupal path for use in a URL. + * + * For aesthetic reasons slashes are not escaped. + * + * @param {string} item + * Unencoded path. + * + * @return {string} + * The encoded path. + */ + Drupal.encodePath = function (item) { + return window.encodeURIComponent(item).replace(/%2F/g, '/'); + }; + + /** + * Generates the themed representation of a Drupal object. + * + * All requests for themed output must go through this function. It examines + * the request and routes it to the appropriate theme function. If the current + * theme does not provide an override function, the generic theme function is + * called. + * + * @example + * <caption>To retrieve the HTML for text that should be emphasized and + * displayed as a placeholder inside a sentence.</caption> + * Drupal.theme('placeholder', text); + * + * @namespace + * + * @param {function} func + * The name of the theme function to call. + * @param {...args} + * Additional arguments to pass along to the theme function. + * + * @return {string|object|HTMLElement|jQuery} + * Any data the theme function returns. This could be a plain HTML string, + * but also a complex object. + */ + Drupal.theme = function (func) { + var args = Array.prototype.slice.apply(arguments, [1]); + if (func in Drupal.theme) { + return Drupal.theme[func].apply(this, args); + } + }; + + /** + * Formats text for emphasized display in a placeholder inside a sentence. + * + * @param {string} str + * The text to format (plain-text). + * + * @return {string} + * The formatted text (html). + */ + Drupal.theme.placeholder = function (str) { + return '<em class="placeholder">' + Drupal.checkPlain(str) + '</em>'; + }; + +})(Drupal, window.drupalSettings, window.drupalTranslations); diff --git a/core/misc/drupal.init.es6.js b/core/misc/drupal.init.es6.js new file mode 100644 index 000000000000..0e55e190efa5 --- /dev/null +++ b/core/misc/drupal.init.es6.js @@ -0,0 +1,19 @@ +// Allow other JavaScript libraries to use $. +if (window.jQuery) { + jQuery.noConflict(); +} + +// Class indicating that JS is enabled; used for styling purpose. +document.documentElement.className += ' js'; + +// JavaScript should be made compatible with libraries other than jQuery by +// wrapping it in an anonymous closure. + +(function (domready, Drupal, drupalSettings) { + + 'use strict'; + + // Attach all behaviors. + domready(function () { Drupal.attachBehaviors(document, drupalSettings); }); + +})(domready, Drupal, window.drupalSettings); diff --git a/core/misc/drupal.init.js b/core/misc/drupal.init.js index 0e55e190efa5..b9331579eaf4 100644 --- a/core/misc/drupal.init.js +++ b/core/misc/drupal.init.js @@ -1,19 +1,22 @@ -// Allow other JavaScript libraries to use $. +/** +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/drupal.init.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + if (window.jQuery) { jQuery.noConflict(); } -// Class indicating that JS is enabled; used for styling purpose. document.documentElement.className += ' js'; -// JavaScript should be made compatible with libraries other than jQuery by -// wrapping it in an anonymous closure. - (function (domready, Drupal, drupalSettings) { 'use strict'; - // Attach all behaviors. - domready(function () { Drupal.attachBehaviors(document, drupalSettings); }); - -})(domready, Drupal, window.drupalSettings); + domready(function () { + Drupal.attachBehaviors(document, drupalSettings); + }); +})(domready, Drupal, window.drupalSettings); \ No newline at end of file diff --git a/core/misc/drupal.js b/core/misc/drupal.js index d509795b864e..12b2e457046e 100644 --- a/core/misc/drupal.js +++ b/core/misc/drupal.js @@ -1,289 +1,75 @@ /** - * @file - * Defines the Drupal JavaScript API. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/drupal.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ -/** - * A jQuery object, typically the return value from a `$(selector)` call. - * - * Holds an HTMLElement or a collection of HTMLElements. - * - * @typedef {object} jQuery - * - * @prop {number} length=0 - * Number of elements contained in the jQuery object. - */ - -/** - * Variable generated by Drupal that holds all translated strings from PHP. - * - * Content of this variable is automatically created by Drupal when using the - * Interface Translation module. It holds the translation of strings used on - * the page. - * - * This variable is used to pass data from the backend to the frontend. Data - * contained in `drupalSettings` is used during behavior initialization. - * - * @global - * - * @var {object} drupalTranslations - */ +window.Drupal = { behaviors: {}, locale: {} }; -/** - * Global Drupal object. - * - * All Drupal JavaScript APIs are contained in this namespace. - * - * @global - * - * @namespace - */ -window.Drupal = {behaviors: {}, locale: {}}; - -// JavaScript should be made compatible with libraries other than jQuery by -// wrapping it in an anonymous closure. (function (Drupal, drupalSettings, drupalTranslations) { 'use strict'; - /** - * Helper to rethrow errors asynchronously. - * - * This way Errors bubbles up outside of the original callstack, making it - * easier to debug errors in the browser. - * - * @param {Error|string} error - * The error to be thrown. - */ Drupal.throwError = function (error) { - setTimeout(function () { throw error; }, 0); + setTimeout(function () { + throw error; + }, 0); }; - /** - * Custom error thrown after attach/detach if one or more behaviors failed. - * Initializes the JavaScript behaviors for page loads and Ajax requests. - * - * @callback Drupal~behaviorAttach - * - * @param {HTMLDocument|HTMLElement} context - * An element to detach behaviors from. - * @param {?object} settings - * An object containing settings for the current context. It is rarely used. - * - * @see Drupal.attachBehaviors - */ - - /** - * Reverts and cleans up JavaScript behavior initialization. - * - * @callback Drupal~behaviorDetach - * - * @param {HTMLDocument|HTMLElement} context - * An element to attach behaviors to. - * @param {object} settings - * An object containing settings for the current context. - * @param {string} trigger - * One of `'unload'`, `'move'`, or `'serialize'`. - * - * @see Drupal.detachBehaviors - */ - - /** - * @typedef {object} Drupal~behavior - * - * @prop {Drupal~behaviorAttach} attach - * Function run on page load and after an Ajax call. - * @prop {Drupal~behaviorDetach} detach - * Function run when content is serialized or removed from the page. - */ - - /** - * Holds all initialization methods. - * - * @namespace Drupal.behaviors - * - * @type {Object.<string, Drupal~behavior>} - */ - - /** - * Defines a behavior to be run during attach and detach phases. - * - * Attaches all registered behaviors to a page element. - * - * Behaviors are event-triggered actions that attach to page elements, - * enhancing default non-JavaScript UIs. Behaviors are registered in the - * {@link Drupal.behaviors} object using the method 'attach' and optionally - * also 'detach'. - * - * {@link Drupal.attachBehaviors} is added below to the `jQuery.ready` event - * and therefore runs on initial page load. Developers implementing Ajax in - * their solutions should also call this function after new page content has - * been loaded, feeding in an element to be processed, in order to attach all - * behaviors to the new content. - * - * Behaviors should use `var elements = - * $(context).find(selector).once('behavior-name');` to ensure the behavior is - * attached only once to a given element. (Doing so enables the reprocessing - * of given elements, which may be needed on occasion despite the ability to - * limit behavior attachment to a particular element.) - * - * @example - * Drupal.behaviors.behaviorName = { - * attach: function (context, settings) { - * // ... - * }, - * detach: function (context, settings, trigger) { - * // ... - * } - * }; - * - * @param {HTMLDocument|HTMLElement} [context=document] - * An element to attach behaviors to. - * @param {object} [settings=drupalSettings] - * An object containing settings for the current context. If none is given, - * the global {@link drupalSettings} object is used. - * - * @see Drupal~behaviorAttach - * @see Drupal.detachBehaviors - * - * @throws {Drupal~DrupalBehaviorError} - */ Drupal.attachBehaviors = function (context, settings) { context = context || document; settings = settings || drupalSettings; var behaviors = Drupal.behaviors; - // Execute all of them. + for (var i in behaviors) { if (behaviors.hasOwnProperty(i) && typeof behaviors[i].attach === 'function') { - // Don't stop the execution of behaviors in case of an error. try { behaviors[i].attach(context, settings); - } - catch (e) { + } catch (e) { Drupal.throwError(e); } } } }; - /** - * Detaches registered behaviors from a page element. - * - * Developers implementing Ajax in their solutions should call this function - * before page content is about to be removed, feeding in an element to be - * processed, in order to allow special behaviors to detach from the content. - * - * Such implementations should use `.findOnce()` and `.removeOnce()` to find - * elements with their corresponding `Drupal.behaviors.behaviorName.attach` - * implementation, i.e. `.removeOnce('behaviorName')`, to ensure the behavior - * is detached only from previously processed elements. - * - * @param {HTMLDocument|HTMLElement} [context=document] - * An element to detach behaviors from. - * @param {object} [settings=drupalSettings] - * An object containing settings for the current context. If none given, - * the global {@link drupalSettings} object is used. - * @param {string} [trigger='unload'] - * A string containing what's causing the behaviors to be detached. The - * possible triggers are: - * - `'unload'`: The context element is being removed from the DOM. - * - `'move'`: The element is about to be moved within the DOM (for example, - * during a tabledrag row swap). After the move is completed, - * {@link Drupal.attachBehaviors} is called, so that the behavior can undo - * whatever it did in response to the move. Many behaviors won't need to - * do anything simply in response to the element being moved, but because - * IFRAME elements reload their "src" when being moved within the DOM, - * behaviors bound to IFRAME elements (like WYSIWYG editors) may need to - * take some action. - * - `'serialize'`: When an Ajax form is submitted, this is called with the - * form as the context. This provides every behavior within the form an - * opportunity to ensure that the field elements have correct content - * in them before the form is serialized. The canonical use-case is so - * that WYSIWYG editors can update the hidden textarea to which they are - * bound. - * - * @throws {Drupal~DrupalBehaviorError} - * - * @see Drupal~behaviorDetach - * @see Drupal.attachBehaviors - */ Drupal.detachBehaviors = function (context, settings, trigger) { context = context || document; settings = settings || drupalSettings; trigger = trigger || 'unload'; var behaviors = Drupal.behaviors; - // Execute all of them. + for (var i in behaviors) { if (behaviors.hasOwnProperty(i) && typeof behaviors[i].detach === 'function') { - // Don't stop the execution of behaviors in case of an error. try { behaviors[i].detach(context, settings, trigger); - } - catch (e) { + } catch (e) { Drupal.throwError(e); } } } }; - /** - * Encodes special characters in a plain-text string for display as HTML. - * - * @param {string} str - * The string to be encoded. - * - * @return {string} - * The encoded string. - * - * @ingroup sanitization - */ Drupal.checkPlain = function (str) { - str = str.toString() - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/</g, '<') - .replace(/>/g, '>'); + str = str.toString().replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); return str; }; - /** - * Replaces placeholders with sanitized values in a string. - * - * @param {string} str - * A string with placeholders. - * @param {object} args - * An object of replacements pairs to make. Incidences of any key in this - * array are replaced with the corresponding value. Based on the first - * character of the key, the value is escaped and/or themed: - * - `'!variable'`: inserted as is. - * - `'@variable'`: escape plain text to HTML ({@link Drupal.checkPlain}). - * - `'%variable'`: escape text and theme as a placeholder for user- - * submitted content ({@link Drupal.checkPlain} + - * `{@link Drupal.theme}('placeholder')`). - * - * @return {string} - * The formatted string. - * - * @see Drupal.t - */ Drupal.formatString = function (str, args) { - // Keep args intact. var processedArgs = {}; - // Transform arguments before inserting them. + for (var key in args) { if (args.hasOwnProperty(key)) { switch (key.charAt(0)) { - // Escaped only. case '@': processedArgs[key] = Drupal.checkPlain(args[key]); break; - // Pass-through. case '!': processedArgs[key] = args[key]; break; - // Escaped and placeholder. default: processedArgs[key] = Drupal.theme('placeholder', args[key]); break; @@ -294,28 +80,11 @@ window.Drupal = {behaviors: {}, locale: {}}; return Drupal.stringReplace(str, processedArgs, null); }; - /** - * Replaces substring. - * - * The longest keys will be tried first. Once a substring has been replaced, - * its new value will not be searched again. - * - * @param {string} str - * A string with placeholders. - * @param {object} args - * Key-value pairs. - * @param {Array|null} keys - * Array of keys from `args`. Internal use only. - * - * @return {string} - * The replaced string. - */ Drupal.stringReplace = function (str, args, keys) { if (str.length === 0) { return str; } - // If the array of keys is not passed then collect the keys from the args. if (!Array.isArray(keys)) { keys = []; for (var k in args) { @@ -324,21 +93,20 @@ window.Drupal = {behaviors: {}, locale: {}}; } } - // Order the keys by the character length. The shortest one is the first. - keys.sort(function (a, b) { return a.length - b.length; }); + keys.sort(function (a, b) { + return a.length - b.length; + }); } if (keys.length === 0) { return str; } - // Take next longest one from the end. var key = keys.pop(); var fragments = str.split(key); if (keys.length) { for (var i = 0; i < fragments.length; i++) { - // Process each fragment with a copy of remaining keys. fragments[i] = Drupal.stringReplace(fragments[i], args, keys.slice(0)); } } @@ -346,31 +114,10 @@ window.Drupal = {behaviors: {}, locale: {}}; return fragments.join(args[key]); }; - /** - * Translates strings to the page language, or a given language. - * - * See the documentation of the server-side t() function for further details. - * - * @param {string} str - * A string containing the English text to translate. - * @param {Object.<string, string>} [args] - * An object of replacements pairs to make after translation. Incidences - * of any key in this array are replaced with the corresponding value. - * See {@link Drupal.formatString}. - * @param {object} [options] - * Additional options for translation. - * @param {string} [options.context=''] - * The context the source string belongs to. - * - * @return {string} - * The formatted string. - * The translated string. - */ Drupal.t = function (str, args, options) { options = options || {}; options.context = options.context || ''; - // Fetch the localized version of the string. if (typeof drupalTranslations !== 'undefined' && drupalTranslations.strings && drupalTranslations.strings[options.context] && drupalTranslations.strings[options.context][str]) { str = drupalTranslations.strings[options.context][str]; } @@ -381,127 +128,41 @@ window.Drupal = {behaviors: {}, locale: {}}; return str; }; - /** - * Returns the URL to a Drupal page. - * - * @param {string} path - * Drupal path to transform to URL. - * - * @return {string} - * The full URL. - */ Drupal.url = function (path) { return drupalSettings.path.baseUrl + drupalSettings.path.pathPrefix + path; }; - /** - * Returns the passed in URL as an absolute URL. - * - * @param {string} url - * The URL string to be normalized to an absolute URL. - * - * @return {string} - * The normalized, absolute URL. - * - * @see https://github.com/angular/angular.js/blob/v1.4.4/src/ng/urlUtils.js - * @see https://grack.com/blog/2009/11/17/absolutizing-url-in-javascript - * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L53 - */ Drupal.url.toAbsolute = function (url) { var urlParsingNode = document.createElement('a'); - // Decode the URL first; this is required by IE <= 6. Decoding non-UTF-8 - // strings may throw an exception. try { url = decodeURIComponent(url); - } - catch (e) { - // Empty. - } + } catch (e) {} urlParsingNode.setAttribute('href', url); - // IE <= 7 normalizes the URL when assigned to the anchor node similar to - // the other browsers. return urlParsingNode.cloneNode(false).href; }; - /** - * Returns true if the URL is within Drupal's base path. - * - * @param {string} url - * The URL string to be tested. - * - * @return {bool} - * `true` if local. - * - * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L58 - */ Drupal.url.isLocal = function (url) { - // Always use browser-derived absolute URLs in the comparison, to avoid - // attempts to break out of the base path using directory traversal. var absoluteUrl = Drupal.url.toAbsolute(url); var protocol = location.protocol; - // Consider URLs that match this site's base URL but use HTTPS instead of HTTP - // as local as well. if (protocol === 'http:' && absoluteUrl.indexOf('https:') === 0) { protocol = 'https:'; } var baseUrl = protocol + '//' + location.host + drupalSettings.path.baseUrl.slice(0, -1); - // Decoding non-UTF-8 strings may throw an exception. try { absoluteUrl = decodeURIComponent(absoluteUrl); - } - catch (e) { - // Empty. - } + } catch (e) {} try { baseUrl = decodeURIComponent(baseUrl); - } - catch (e) { - // Empty. - } + } catch (e) {} - // The given URL matches the site's base URL, or has a path under the site's - // base URL. return absoluteUrl === baseUrl || absoluteUrl.indexOf(baseUrl + '/') === 0; }; - /** - * Formats a string containing a count of items. - * - * This function ensures that the string is pluralized correctly. Since - * {@link Drupal.t} is called by this function, make sure not to pass - * already-localized strings to it. - * - * See the documentation of the server-side - * \Drupal\Core\StringTranslation\TranslationInterface::formatPlural() - * function for more details. - * - * @param {number} count - * The item count to display. - * @param {string} singular - * The string for the singular case. Please make sure it is clear this is - * singular, to ease translation (e.g. use "1 new comment" instead of "1 - * new"). Do not use @count in the singular string. - * @param {string} plural - * The string for the plural case. Please make sure it is clear this is - * plural, to ease translation. Use @count in place of the item count, as in - * "@count new comments". - * @param {object} [args] - * An object of replacements pairs to make after translation. Incidences - * of any key in this array are replaced with the corresponding value. - * See {@link Drupal.formatString}. - * Note that you do not need to include @count in this array. - * This replacement is done automatically for the plural case. - * @param {object} [options] - * The options to pass to the {@link Drupal.t} function. - * - * @return {string} - * A translated string. - */ Drupal.formatPlural = function (count, singular, plural, args, options) { args = args || {}; args['@count'] = count; @@ -510,56 +171,19 @@ window.Drupal = {behaviors: {}, locale: {}}; var translations = Drupal.t(singular + pluralDelimiter + plural, args, options).split(pluralDelimiter); var index = 0; - // Determine the index of the plural form. if (typeof drupalTranslations !== 'undefined' && drupalTranslations.pluralFormula) { index = count in drupalTranslations.pluralFormula ? drupalTranslations.pluralFormula[count] : drupalTranslations.pluralFormula['default']; - } - else if (args['@count'] !== 1) { + } else if (args['@count'] !== 1) { index = 1; } return translations[index]; }; - /** - * Encodes a Drupal path for use in a URL. - * - * For aesthetic reasons slashes are not escaped. - * - * @param {string} item - * Unencoded path. - * - * @return {string} - * The encoded path. - */ Drupal.encodePath = function (item) { return window.encodeURIComponent(item).replace(/%2F/g, '/'); }; - /** - * Generates the themed representation of a Drupal object. - * - * All requests for themed output must go through this function. It examines - * the request and routes it to the appropriate theme function. If the current - * theme does not provide an override function, the generic theme function is - * called. - * - * @example - * <caption>To retrieve the HTML for text that should be emphasized and - * displayed as a placeholder inside a sentence.</caption> - * Drupal.theme('placeholder', text); - * - * @namespace - * - * @param {function} func - * The name of the theme function to call. - * @param {...args} - * Additional arguments to pass along to the theme function. - * - * @return {string|object|HTMLElement|jQuery} - * Any data the theme function returns. This could be a plain HTML string, - * but also a complex object. - */ Drupal.theme = function (func) { var args = Array.prototype.slice.apply(arguments, [1]); if (func in Drupal.theme) { @@ -567,17 +191,7 @@ window.Drupal = {behaviors: {}, locale: {}}; } }; - /** - * Formats text for emphasized display in a placeholder inside a sentence. - * - * @param {string} str - * The text to format (plain-text). - * - * @return {string} - * The formatted text (html). - */ Drupal.theme.placeholder = function (str) { return '<em class="placeholder">' + Drupal.checkPlain(str) + '</em>'; }; - -})(Drupal, window.drupalSettings, window.drupalTranslations); +})(Drupal, window.drupalSettings, window.drupalTranslations); \ No newline at end of file diff --git a/core/misc/drupalSettingsLoader.es6.js b/core/misc/drupalSettingsLoader.es6.js new file mode 100644 index 000000000000..7ff292efbfc5 --- /dev/null +++ b/core/misc/drupalSettingsLoader.es6.js @@ -0,0 +1,25 @@ +/** + * @file + * Parse inline JSON and initialize the drupalSettings global object. + */ + +(function () { + + 'use strict'; + + // Use direct child elements to harden against XSS exploits when CSP is on. + var settingsElement = document.querySelector('head > script[type="application/json"][data-drupal-selector="drupal-settings-json"], body > script[type="application/json"][data-drupal-selector="drupal-settings-json"]'); + + /** + * Variable generated by Drupal with all the configuration created from PHP. + * + * @global + * + * @type {object} + */ + window.drupalSettings = {}; + + if (settingsElement !== null) { + window.drupalSettings = JSON.parse(settingsElement.textContent); + } +})(); diff --git a/core/misc/drupalSettingsLoader.js b/core/misc/drupalSettingsLoader.js index 7ff292efbfc5..dbc2d2b20c36 100644 --- a/core/misc/drupalSettingsLoader.js +++ b/core/misc/drupalSettingsLoader.js @@ -1,25 +1,20 @@ /** - * @file - * Parse inline JSON and initialize the drupalSettings global object. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/drupalSettingsLoader.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function () { 'use strict'; - // Use direct child elements to harden against XSS exploits when CSP is on. var settingsElement = document.querySelector('head > script[type="application/json"][data-drupal-selector="drupal-settings-json"], body > script[type="application/json"][data-drupal-selector="drupal-settings-json"]'); - /** - * Variable generated by Drupal with all the configuration created from PHP. - * - * @global - * - * @type {object} - */ window.drupalSettings = {}; if (settingsElement !== null) { window.drupalSettings = JSON.parse(settingsElement.textContent); } -})(); +})(); \ No newline at end of file diff --git a/core/misc/entity-form.es6.js b/core/misc/entity-form.es6.js new file mode 100644 index 000000000000..87253c90223e --- /dev/null +++ b/core/misc/entity-form.es6.js @@ -0,0 +1,57 @@ +/** + * @file + * Defines Javascript behaviors for the block_content module. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Sets summaries about revision and translation of entities. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behaviour entity form tabs. + * + * Specifically, it updates summaries to the revision information and the + * translation options. + */ + Drupal.behaviors.entityContentDetailsSummaries = { + attach: function (context) { + var $context = $(context); + $context.find('.entity-content-form-revision-information').drupalSetSummary(function (context) { + var $revisionContext = $(context); + var revisionCheckbox = $revisionContext.find('.js-form-item-revision input'); + + // Return 'New revision' if the 'Create new revision' checkbox is checked, + // or if the checkbox doesn't exist, but the revision log does. For users + // without the "Administer content" permission the checkbox won't appear, + // but the revision log will if the content type is set to auto-revision. + if (revisionCheckbox.is(':checked') || (!revisionCheckbox.length && $revisionContext.find('.js-form-item-revision-log textarea').length)) { + return Drupal.t('New revision'); + } + + return Drupal.t('No revision'); + }); + + $context.find('details.entity-translation-options').drupalSetSummary(function (context) { + var $translationContext = $(context); + var translate; + var $checkbox = $translationContext.find('.js-form-item-translation-translate input'); + + if ($checkbox.length) { + translate = $checkbox.is(':checked') ? Drupal.t('Needs to be updated') : Drupal.t('Does not need to be updated'); + } + else { + $checkbox = $translationContext.find('.js-form-item-translation-retranslate input'); + translate = $checkbox.is(':checked') ? Drupal.t('Flag other translations as outdated') : Drupal.t('Do not flag other translations as outdated'); + } + + return translate; + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/entity-form.js b/core/misc/entity-form.js index 87253c90223e..c00f84f4e4f7 100644 --- a/core/misc/entity-form.js +++ b/core/misc/entity-form.js @@ -1,35 +1,23 @@ /** - * @file - * Defines Javascript behaviors for the block_content module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/entity-form.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Sets summaries about revision and translation of entities. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behaviour entity form tabs. - * - * Specifically, it updates summaries to the revision information and the - * translation options. - */ Drupal.behaviors.entityContentDetailsSummaries = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); $context.find('.entity-content-form-revision-information').drupalSetSummary(function (context) { var $revisionContext = $(context); var revisionCheckbox = $revisionContext.find('.js-form-item-revision input'); - // Return 'New revision' if the 'Create new revision' checkbox is checked, - // or if the checkbox doesn't exist, but the revision log does. For users - // without the "Administer content" permission the checkbox won't appear, - // but the revision log will if the content type is set to auto-revision. - if (revisionCheckbox.is(':checked') || (!revisionCheckbox.length && $revisionContext.find('.js-form-item-revision-log textarea').length)) { + if (revisionCheckbox.is(':checked') || !revisionCheckbox.length && $revisionContext.find('.js-form-item-revision-log textarea').length) { return Drupal.t('New revision'); } @@ -43,8 +31,7 @@ if ($checkbox.length) { translate = $checkbox.is(':checked') ? Drupal.t('Needs to be updated') : Drupal.t('Does not need to be updated'); - } - else { + } else { $checkbox = $translationContext.find('.js-form-item-translation-retranslate input'); translate = $checkbox.is(':checked') ? Drupal.t('Flag other translations as outdated') : Drupal.t('Do not flag other translations as outdated'); } @@ -53,5 +40,4 @@ }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/form.es6.js b/core/misc/form.es6.js new file mode 100644 index 000000000000..7ca64fc4257c --- /dev/null +++ b/core/misc/form.es6.js @@ -0,0 +1,250 @@ +/** + * @file + * Form features. + */ + +/** + * Triggers when a value in the form changed. + * + * The event triggers when content is typed or pasted in a text field, before + * the change event triggers. + * + * @event formUpdated + */ + +(function ($, Drupal, debounce) { + + 'use strict'; + + /** + * Retrieves the summary for the first element. + * + * @return {string} + * The text of the summary. + */ + $.fn.drupalGetSummary = function () { + var callback = this.data('summaryCallback'); + return (this[0] && callback) ? $.trim(callback(this[0])) : ''; + }; + + /** + * Sets the summary for all matched elements. + * + * @param {function} callback + * Either a function that will be called each time the summary is + * retrieved or a string (which is returned each time). + * + * @return {jQuery} + * jQuery collection of the current element. + * + * @fires event:summaryUpdated + * + * @listens event:formUpdated + */ + $.fn.drupalSetSummary = function (callback) { + var self = this; + + // To facilitate things, the callback should always be a function. If it's + // not, we wrap it into an anonymous function which just returns the value. + if (typeof callback !== 'function') { + var val = callback; + callback = function () { return val; }; + } + + return this + .data('summaryCallback', callback) + // To prevent duplicate events, the handlers are first removed and then + // (re-)added. + .off('formUpdated.summary') + .on('formUpdated.summary', function () { + self.trigger('summaryUpdated'); + }) + // The actual summaryUpdated handler doesn't fire when the callback is + // changed, so we have to do this manually. + .trigger('summaryUpdated'); + }; + + /** + * Prevents consecutive form submissions of identical form values. + * + * Repetitive form submissions that would submit the identical form values + * are prevented, unless the form values are different to the previously + * submitted values. + * + * This is a simplified re-implementation of a user-agent behavior that + * should be natively supported by major web browsers, but at this time, only + * Firefox has a built-in protection. + * + * A form value-based approach ensures that the constraint is triggered for + * consecutive, identical form submissions only. Compared to that, a form + * button-based approach would (1) rely on [visible] buttons to exist where + * technically not required and (2) require more complex state management if + * there are multiple buttons in a form. + * + * This implementation is based on form-level submit events only and relies + * on jQuery's serialize() method to determine submitted form values. As such, + * the following limitations exist: + * + * - Event handlers on form buttons that preventDefault() do not receive a + * double-submit protection. That is deemed to be fine, since such button + * events typically trigger reversible client-side or server-side + * operations that are local to the context of a form only. + * - Changed values in advanced form controls, such as file inputs, are not + * part of the form values being compared between consecutive form submits + * (due to limitations of jQuery.serialize()). That is deemed to be + * acceptable, because if the user forgot to attach a file, then the size of + * HTTP payload will most likely be small enough to be fully passed to the + * server endpoint within (milli)seconds. If a user mistakenly attached a + * wrong file and is technically versed enough to cancel the form submission + * (and HTTP payload) in order to attach a different file, then that + * edge-case is not supported here. + * + * Lastly, all forms submitted via HTTP GET are idempotent by definition of + * HTTP standards, so excluded in this implementation. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.formSingleSubmit = { + attach: function () { + function onFormSubmit(e) { + var $form = $(e.currentTarget); + var formValues = $form.serialize(); + var previousValues = $form.attr('data-drupal-form-submit-last'); + if (previousValues === formValues) { + e.preventDefault(); + } + else { + $form.attr('data-drupal-form-submit-last', formValues); + } + } + + $('body').once('form-single-submit') + .on('submit.singleSubmit', 'form:not([method~="GET"])', onFormSubmit); + } + }; + + /** + * Sends a 'formUpdated' event each time a form element is modified. + * + * @param {HTMLElement} element + * The element to trigger a form updated event on. + * + * @fires event:formUpdated + */ + function triggerFormUpdated(element) { + $(element).trigger('formUpdated'); + } + + /** + * Collects the IDs of all form fields in the given form. + * + * @param {HTMLFormElement} form + * The form element to search. + * + * @return {Array} + * Array of IDs for form fields. + */ + function fieldsList(form) { + var $fieldList = $(form).find('[name]').map(function (index, element) { + // We use id to avoid name duplicates on radio fields and filter out + // elements with a name but no id. + return element.getAttribute('id'); + }); + // Return a true array. + return $.makeArray($fieldList); + } + + /** + * Triggers the 'formUpdated' event on form elements when they are modified. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches formUpdated behaviors. + * @prop {Drupal~behaviorDetach} detach + * Detaches formUpdated behaviors. + * + * @fires event:formUpdated + */ + Drupal.behaviors.formUpdated = { + attach: function (context) { + var $context = $(context); + var contextIsForm = $context.is('form'); + var $forms = (contextIsForm ? $context : $context.find('form')).once('form-updated'); + var formFields; + + if ($forms.length) { + // Initialize form behaviors, use $.makeArray to be able to use native + // forEach array method and have the callback parameters in the right + // order. + $.makeArray($forms).forEach(function (form) { + var events = 'change.formUpdated input.formUpdated '; + var eventHandler = debounce(function (event) { triggerFormUpdated(event.target); }, 300); + formFields = fieldsList(form).join(','); + + form.setAttribute('data-drupal-form-fields', formFields); + $(form).on(events, eventHandler); + }); + } + // On ajax requests context is the form element. + if (contextIsForm) { + formFields = fieldsList(context).join(','); + // @todo replace with form.getAttribute() when #1979468 is in. + var currentFields = $(context).attr('data-drupal-form-fields'); + // If there has been a change in the fields or their order, trigger + // formUpdated. + if (formFields !== currentFields) { + triggerFormUpdated(context); + } + } + + }, + detach: function (context, settings, trigger) { + var $context = $(context); + var contextIsForm = $context.is('form'); + if (trigger === 'unload') { + var $forms = (contextIsForm ? $context : $context.find('form')).removeOnce('form-updated'); + if ($forms.length) { + $.makeArray($forms).forEach(function (form) { + form.removeAttribute('data-drupal-form-fields'); + $(form).off('.formUpdated'); + }); + } + } + } + }; + + /** + * Prepopulate form fields with information from the visitor browser. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior for filling user info from browser. + */ + Drupal.behaviors.fillUserInfoFromBrowser = { + attach: function (context, settings) { + var userInfo = ['name', 'mail', 'homepage']; + var $forms = $('[data-user-info-from-browser]').once('user-info-from-browser'); + if ($forms.length) { + userInfo.map(function (info) { + var $element = $forms.find('[name=' + info + ']'); + var browserData = localStorage.getItem('Drupal.visitor.' + info); + var emptyOrDefault = ($element.val() === '' || ($element.attr('data-drupal-default-value') === $element.val())); + if ($element.length && emptyOrDefault && browserData) { + $element.val(browserData); + } + }); + } + $forms.on('submit', function () { + userInfo.map(function (info) { + var $element = $forms.find('[name=' + info + ']'); + if ($element.length) { + localStorage.setItem('Drupal.visitor.' + info, $element.val()); + } + }); + }); + } + }; + +})(jQuery, Drupal, Drupal.debounce); diff --git a/core/misc/form.js b/core/misc/form.js index 7ca64fc4257c..5c3625cc2568 100644 --- a/core/misc/form.js +++ b/core/misc/form.js @@ -1,205 +1,95 @@ /** - * @file - * Form features. - */ - -/** - * Triggers when a value in the form changed. - * - * The event triggers when content is typed or pasted in a text field, before - * the change event triggers. - * - * @event formUpdated - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/form.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, debounce) { 'use strict'; - /** - * Retrieves the summary for the first element. - * - * @return {string} - * The text of the summary. - */ $.fn.drupalGetSummary = function () { var callback = this.data('summaryCallback'); - return (this[0] && callback) ? $.trim(callback(this[0])) : ''; + return this[0] && callback ? $.trim(callback(this[0])) : ''; }; - /** - * Sets the summary for all matched elements. - * - * @param {function} callback - * Either a function that will be called each time the summary is - * retrieved or a string (which is returned each time). - * - * @return {jQuery} - * jQuery collection of the current element. - * - * @fires event:summaryUpdated - * - * @listens event:formUpdated - */ $.fn.drupalSetSummary = function (callback) { var self = this; - // To facilitate things, the callback should always be a function. If it's - // not, we wrap it into an anonymous function which just returns the value. if (typeof callback !== 'function') { var val = callback; - callback = function () { return val; }; + callback = function callback() { + return val; + }; } - return this - .data('summaryCallback', callback) - // To prevent duplicate events, the handlers are first removed and then - // (re-)added. - .off('formUpdated.summary') - .on('formUpdated.summary', function () { - self.trigger('summaryUpdated'); - }) - // The actual summaryUpdated handler doesn't fire when the callback is - // changed, so we have to do this manually. - .trigger('summaryUpdated'); + return this.data('summaryCallback', callback).off('formUpdated.summary').on('formUpdated.summary', function () { + self.trigger('summaryUpdated'); + }).trigger('summaryUpdated'); }; - /** - * Prevents consecutive form submissions of identical form values. - * - * Repetitive form submissions that would submit the identical form values - * are prevented, unless the form values are different to the previously - * submitted values. - * - * This is a simplified re-implementation of a user-agent behavior that - * should be natively supported by major web browsers, but at this time, only - * Firefox has a built-in protection. - * - * A form value-based approach ensures that the constraint is triggered for - * consecutive, identical form submissions only. Compared to that, a form - * button-based approach would (1) rely on [visible] buttons to exist where - * technically not required and (2) require more complex state management if - * there are multiple buttons in a form. - * - * This implementation is based on form-level submit events only and relies - * on jQuery's serialize() method to determine submitted form values. As such, - * the following limitations exist: - * - * - Event handlers on form buttons that preventDefault() do not receive a - * double-submit protection. That is deemed to be fine, since such button - * events typically trigger reversible client-side or server-side - * operations that are local to the context of a form only. - * - Changed values in advanced form controls, such as file inputs, are not - * part of the form values being compared between consecutive form submits - * (due to limitations of jQuery.serialize()). That is deemed to be - * acceptable, because if the user forgot to attach a file, then the size of - * HTTP payload will most likely be small enough to be fully passed to the - * server endpoint within (milli)seconds. If a user mistakenly attached a - * wrong file and is technically versed enough to cancel the form submission - * (and HTTP payload) in order to attach a different file, then that - * edge-case is not supported here. - * - * Lastly, all forms submitted via HTTP GET are idempotent by definition of - * HTTP standards, so excluded in this implementation. - * - * @type {Drupal~behavior} - */ Drupal.behaviors.formSingleSubmit = { - attach: function () { + attach: function attach() { function onFormSubmit(e) { var $form = $(e.currentTarget); var formValues = $form.serialize(); var previousValues = $form.attr('data-drupal-form-submit-last'); if (previousValues === formValues) { e.preventDefault(); - } - else { + } else { $form.attr('data-drupal-form-submit-last', formValues); } } - $('body').once('form-single-submit') - .on('submit.singleSubmit', 'form:not([method~="GET"])', onFormSubmit); + $('body').once('form-single-submit').on('submit.singleSubmit', 'form:not([method~="GET"])', onFormSubmit); } }; - /** - * Sends a 'formUpdated' event each time a form element is modified. - * - * @param {HTMLElement} element - * The element to trigger a form updated event on. - * - * @fires event:formUpdated - */ function triggerFormUpdated(element) { $(element).trigger('formUpdated'); } - /** - * Collects the IDs of all form fields in the given form. - * - * @param {HTMLFormElement} form - * The form element to search. - * - * @return {Array} - * Array of IDs for form fields. - */ function fieldsList(form) { var $fieldList = $(form).find('[name]').map(function (index, element) { - // We use id to avoid name duplicates on radio fields and filter out - // elements with a name but no id. return element.getAttribute('id'); }); - // Return a true array. + return $.makeArray($fieldList); } - /** - * Triggers the 'formUpdated' event on form elements when they are modified. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches formUpdated behaviors. - * @prop {Drupal~behaviorDetach} detach - * Detaches formUpdated behaviors. - * - * @fires event:formUpdated - */ Drupal.behaviors.formUpdated = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); var contextIsForm = $context.is('form'); var $forms = (contextIsForm ? $context : $context.find('form')).once('form-updated'); var formFields; if ($forms.length) { - // Initialize form behaviors, use $.makeArray to be able to use native - // forEach array method and have the callback parameters in the right - // order. $.makeArray($forms).forEach(function (form) { var events = 'change.formUpdated input.formUpdated '; - var eventHandler = debounce(function (event) { triggerFormUpdated(event.target); }, 300); + var eventHandler = debounce(function (event) { + triggerFormUpdated(event.target); + }, 300); formFields = fieldsList(form).join(','); form.setAttribute('data-drupal-form-fields', formFields); $(form).on(events, eventHandler); }); } - // On ajax requests context is the form element. + if (contextIsForm) { formFields = fieldsList(context).join(','); - // @todo replace with form.getAttribute() when #1979468 is in. + var currentFields = $(context).attr('data-drupal-form-fields'); - // If there has been a change in the fields or their order, trigger - // formUpdated. + if (formFields !== currentFields) { triggerFormUpdated(context); } } - }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { var $context = $(context); var contextIsForm = $context.is('form'); if (trigger === 'unload') { @@ -214,23 +104,15 @@ } }; - /** - * Prepopulate form fields with information from the visitor browser. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the behavior for filling user info from browser. - */ Drupal.behaviors.fillUserInfoFromBrowser = { - attach: function (context, settings) { + attach: function attach(context, settings) { var userInfo = ['name', 'mail', 'homepage']; var $forms = $('[data-user-info-from-browser]').once('user-info-from-browser'); if ($forms.length) { userInfo.map(function (info) { var $element = $forms.find('[name=' + info + ']'); var browserData = localStorage.getItem('Drupal.visitor.' + info); - var emptyOrDefault = ($element.val() === '' || ($element.attr('data-drupal-default-value') === $element.val())); + var emptyOrDefault = $element.val() === '' || $element.attr('data-drupal-default-value') === $element.val(); if ($element.length && emptyOrDefault && browserData) { $element.val(browserData); } @@ -246,5 +128,4 @@ }); } }; - -})(jQuery, Drupal, Drupal.debounce); +})(jQuery, Drupal, Drupal.debounce); \ No newline at end of file diff --git a/core/misc/machine-name.es6.js b/core/misc/machine-name.es6.js new file mode 100644 index 000000000000..e76292e265cc --- /dev/null +++ b/core/misc/machine-name.es6.js @@ -0,0 +1,211 @@ +/** + * @file + * Machine name functionality. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Attach the machine-readable name form element behavior. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches machine-name behaviors. + */ + Drupal.behaviors.machineName = { + + /** + * Attaches the behavior. + * + * @param {Element} context + * The context for attaching the behavior. + * @param {object} settings + * Settings object. + * @param {object} settings.machineName + * A list of elements to process, keyed by the HTML ID of the form + * element containing the human-readable value. Each element is an object + * defining the following properties: + * - target: The HTML ID of the machine name form element. + * - suffix: The HTML ID of a container to show the machine name preview + * in (usually a field suffix after the human-readable name + * form element). + * - label: The label to show for the machine name preview. + * - replace_pattern: A regular expression (without modifiers) matching + * disallowed characters in the machine name; e.g., '[^a-z0-9]+'. + * - replace: A character to replace disallowed characters with; e.g., + * '_' or '-'. + * - standalone: Whether the preview should stay in its own element + * rather than the suffix of the source element. + * - field_prefix: The #field_prefix of the form element. + * - field_suffix: The #field_suffix of the form element. + */ + attach: function (context, settings) { + var self = this; + var $context = $(context); + var timeout = null; + var xhr = null; + + function clickEditHandler(e) { + var data = e.data; + data.$wrapper.removeClass('visually-hidden'); + data.$target.trigger('focus'); + data.$suffix.hide(); + data.$source.off('.machineName'); + } + + function machineNameHandler(e) { + var data = e.data; + var options = data.options; + var baseValue = $(e.target).val(); + + var rx = new RegExp(options.replace_pattern, 'g'); + var expected = baseValue.toLowerCase().replace(rx, options.replace).substr(0, options.maxlength); + + // Abort the last pending request because the label has changed and it + // is no longer valid. + if (xhr && xhr.readystate !== 4) { + xhr.abort(); + xhr = null; + } + + // Wait 300 milliseconds for Ajax request since the last event to update + // the machine name i.e., after the user has stopped typing. + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (baseValue.toLowerCase() !== expected) { + timeout = setTimeout(function () { + xhr = self.transliterate(baseValue, options).done(function (machine) { + self.showMachineName(machine.substr(0, options.maxlength), data); + }); + }, 300); + } + else { + self.showMachineName(expected, data); + } + } + + Object.keys(settings.machineName).forEach(function (source_id) { + var machine = ''; + var eventData; + var options = settings.machineName[source_id]; + + var $source = $context.find(source_id).addClass('machine-name-source').once('machine-name'); + var $target = $context.find(options.target).addClass('machine-name-target'); + var $suffix = $context.find(options.suffix); + var $wrapper = $target.closest('.js-form-item'); + // All elements have to exist. + if (!$source.length || !$target.length || !$suffix.length || !$wrapper.length) { + return; + } + // Skip processing upon a form validation error on the machine name. + if ($target.hasClass('error')) { + return; + } + // Figure out the maximum length for the machine name. + options.maxlength = $target.attr('maxlength'); + // Hide the form item container of the machine name form element. + $wrapper.addClass('visually-hidden'); + // Determine the initial machine name value. Unless the machine name + // form element is disabled or not empty, the initial default value is + // based on the human-readable form element value. + if ($target.is(':disabled') || $target.val() !== '') { + machine = $target.val(); + } + else if ($source.val() !== '') { + machine = self.transliterate($source.val(), options); + } + // Append the machine name preview to the source field. + var $preview = $('<span class="machine-name-value">' + options.field_prefix + Drupal.checkPlain(machine) + options.field_suffix + '</span>'); + $suffix.empty(); + if (options.label) { + $suffix.append('<span class="machine-name-label">' + options.label + ': </span>'); + } + $suffix.append($preview); + + // If the machine name cannot be edited, stop further processing. + if ($target.is(':disabled')) { + return; + } + + eventData = { + $source: $source, + $target: $target, + $suffix: $suffix, + $wrapper: $wrapper, + $preview: $preview, + options: options + }; + // If it is editable, append an edit link. + var $link = $('<span class="admin-link"><button type="button" class="link">' + Drupal.t('Edit') + '</button></span>').on('click', eventData, clickEditHandler); + $suffix.append($link); + + // Preview the machine name in realtime when the human-readable name + // changes, but only if there is no machine name yet; i.e., only upon + // initial creation, not when editing. + if ($target.val() === '') { + $source.on('formUpdated.machineName', eventData, machineNameHandler) + // Initialize machine name preview. + .trigger('formUpdated.machineName'); + } + + // Add a listener for an invalid event on the machine name input + // to show its container and focus it. + $target.on('invalid', eventData, clickEditHandler); + }); + }, + + showMachineName: function (machine, data) { + var settings = data.options; + // Set the machine name to the transliterated value. + if (machine !== '') { + if (machine !== settings.replace) { + data.$target.val(machine); + data.$preview.html(settings.field_prefix + Drupal.checkPlain(machine) + settings.field_suffix); + } + data.$suffix.show(); + } + else { + data.$suffix.hide(); + data.$target.val(machine); + data.$preview.empty(); + } + }, + + /** + * Transliterate a human-readable name to a machine name. + * + * @param {string} source + * A string to transliterate. + * @param {object} settings + * The machine name settings for the corresponding field. + * @param {string} settings.replace_pattern + * A regular expression (without modifiers) matching disallowed characters + * in the machine name; e.g., '[^a-z0-9]+'. + * @param {string} settings.replace_token + * A token to validate the regular expression. + * @param {string} settings.replace + * A character to replace disallowed characters with; e.g., '_' or '-'. + * @param {number} settings.maxlength + * The maximum length of the machine name. + * + * @return {jQuery} + * The transliterated source string. + */ + transliterate: function (source, settings) { + return $.get(Drupal.url('machine_name/transliterate'), { + text: source, + langcode: drupalSettings.langcode, + replace_pattern: settings.replace_pattern, + replace_token: settings.replace_token, + replace: settings.replace, + lowercase: true + }); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/misc/machine-name.js b/core/misc/machine-name.js index e76292e265cc..16fbdcb8c7c5 100644 --- a/core/misc/machine-name.js +++ b/core/misc/machine-name.js @@ -1,48 +1,17 @@ /** - * @file - * Machine name functionality. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/machine-name.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Attach the machine-readable name form element behavior. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches machine-name behaviors. - */ Drupal.behaviors.machineName = { - - /** - * Attaches the behavior. - * - * @param {Element} context - * The context for attaching the behavior. - * @param {object} settings - * Settings object. - * @param {object} settings.machineName - * A list of elements to process, keyed by the HTML ID of the form - * element containing the human-readable value. Each element is an object - * defining the following properties: - * - target: The HTML ID of the machine name form element. - * - suffix: The HTML ID of a container to show the machine name preview - * in (usually a field suffix after the human-readable name - * form element). - * - label: The label to show for the machine name preview. - * - replace_pattern: A regular expression (without modifiers) matching - * disallowed characters in the machine name; e.g., '[^a-z0-9]+'. - * - replace: A character to replace disallowed characters with; e.g., - * '_' or '-'. - * - standalone: Whether the preview should stay in its own element - * rather than the suffix of the source element. - * - field_prefix: The #field_prefix of the form element. - * - field_suffix: The #field_suffix of the form element. - */ - attach: function (context, settings) { + attach: function attach(context, settings) { var self = this; var $context = $(context); var timeout = null; @@ -64,15 +33,11 @@ var rx = new RegExp(options.replace_pattern, 'g'); var expected = baseValue.toLowerCase().replace(rx, options.replace).substr(0, options.maxlength); - // Abort the last pending request because the label has changed and it - // is no longer valid. if (xhr && xhr.readystate !== 4) { xhr.abort(); xhr = null; } - // Wait 300 milliseconds for Ajax request since the last event to update - // the machine name i.e., after the user has stopped typing. if (timeout) { clearTimeout(timeout); timeout = null; @@ -83,8 +48,7 @@ self.showMachineName(machine.substr(0, options.maxlength), data); }); }, 300); - } - else { + } else { self.showMachineName(expected, data); } } @@ -98,28 +62,25 @@ var $target = $context.find(options.target).addClass('machine-name-target'); var $suffix = $context.find(options.suffix); var $wrapper = $target.closest('.js-form-item'); - // All elements have to exist. + if (!$source.length || !$target.length || !$suffix.length || !$wrapper.length) { return; } - // Skip processing upon a form validation error on the machine name. + if ($target.hasClass('error')) { return; } - // Figure out the maximum length for the machine name. + options.maxlength = $target.attr('maxlength'); - // Hide the form item container of the machine name form element. + $wrapper.addClass('visually-hidden'); - // Determine the initial machine name value. Unless the machine name - // form element is disabled or not empty, the initial default value is - // based on the human-readable form element value. + if ($target.is(':disabled') || $target.val() !== '') { machine = $target.val(); - } - else if ($source.val() !== '') { + } else if ($source.val() !== '') { machine = self.transliterate($source.val(), options); } - // Append the machine name preview to the source field. + var $preview = $('<span class="machine-name-value">' + options.field_prefix + Drupal.checkPlain(machine) + options.field_suffix + '</span>'); $suffix.empty(); if (options.label) { @@ -127,7 +88,6 @@ } $suffix.append($preview); - // If the machine name cannot be edited, stop further processing. if ($target.is(':disabled')) { return; } @@ -140,63 +100,35 @@ $preview: $preview, options: options }; - // If it is editable, append an edit link. + var $link = $('<span class="admin-link"><button type="button" class="link">' + Drupal.t('Edit') + '</button></span>').on('click', eventData, clickEditHandler); $suffix.append($link); - // Preview the machine name in realtime when the human-readable name - // changes, but only if there is no machine name yet; i.e., only upon - // initial creation, not when editing. if ($target.val() === '') { - $source.on('formUpdated.machineName', eventData, machineNameHandler) - // Initialize machine name preview. - .trigger('formUpdated.machineName'); + $source.on('formUpdated.machineName', eventData, machineNameHandler).trigger('formUpdated.machineName'); } - // Add a listener for an invalid event on the machine name input - // to show its container and focus it. $target.on('invalid', eventData, clickEditHandler); }); }, - showMachineName: function (machine, data) { + showMachineName: function showMachineName(machine, data) { var settings = data.options; - // Set the machine name to the transliterated value. + if (machine !== '') { if (machine !== settings.replace) { data.$target.val(machine); data.$preview.html(settings.field_prefix + Drupal.checkPlain(machine) + settings.field_suffix); } data.$suffix.show(); - } - else { + } else { data.$suffix.hide(); data.$target.val(machine); data.$preview.empty(); } }, - /** - * Transliterate a human-readable name to a machine name. - * - * @param {string} source - * A string to transliterate. - * @param {object} settings - * The machine name settings for the corresponding field. - * @param {string} settings.replace_pattern - * A regular expression (without modifiers) matching disallowed characters - * in the machine name; e.g., '[^a-z0-9]+'. - * @param {string} settings.replace_token - * A token to validate the regular expression. - * @param {string} settings.replace - * A character to replace disallowed characters with; e.g., '_' or '-'. - * @param {number} settings.maxlength - * The maximum length of the machine name. - * - * @return {jQuery} - * The transliterated source string. - */ - transliterate: function (source, settings) { + transliterate: function transliterate(source, settings) { return $.get(Drupal.url('machine_name/transliterate'), { text: source, langcode: drupalSettings.langcode, @@ -207,5 +139,4 @@ }); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/misc/progress.es6.js b/core/misc/progress.es6.js new file mode 100644 index 000000000000..a6694892b270 --- /dev/null +++ b/core/misc/progress.es6.js @@ -0,0 +1,169 @@ +/** + * @file + * Progress bar. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Theme function for the progress bar. + * + * @param {string} id + * The id for the progress bar. + * + * @return {string} + * The HTML for the progress bar. + */ + Drupal.theme.progressBar = function (id) { + return '<div id="' + id + '" class="progress" aria-live="polite">' + + '<div class="progress__label"> </div>' + + '<div class="progress__track"><div class="progress__bar"></div></div>' + + '<div class="progress__percentage"></div>' + + '<div class="progress__description"> </div>' + + '</div>'; + }; + + /** + * A progressbar object. Initialized with the given id. Must be inserted into + * the DOM afterwards through progressBar.element. + * + * Method is the function which will perform the HTTP request to get the + * progress bar state. Either "GET" or "POST". + * + * @example + * pb = new Drupal.ProgressBar('myProgressBar'); + * some_element.appendChild(pb.element); + * + * @constructor + * + * @param {string} id + * The id for the progressbar. + * @param {function} updateCallback + * Callback to run on update. + * @param {string} method + * HTTP method to use. + * @param {function} errorCallback + * Callback to call on error. + */ + Drupal.ProgressBar = function (id, updateCallback, method, errorCallback) { + this.id = id; + this.method = method || 'GET'; + this.updateCallback = updateCallback; + this.errorCallback = errorCallback; + + // The WAI-ARIA setting aria-live="polite" will announce changes after + // users + // have completed their current activity and not interrupt the screen + // reader. + this.element = $(Drupal.theme('progressBar', id)); + }; + + $.extend(Drupal.ProgressBar.prototype, /** @lends Drupal.ProgressBar# */{ + + /** + * Set the percentage and status message for the progressbar. + * + * @param {number} percentage + * The progress percentage. + * @param {string} message + * The message to show the user. + * @param {string} label + * The text for the progressbar label. + */ + setProgress: function (percentage, message, label) { + if (percentage >= 0 && percentage <= 100) { + $(this.element).find('div.progress__bar').css('width', percentage + '%'); + $(this.element).find('div.progress__percentage').html(percentage + '%'); + } + $('div.progress__description', this.element).html(message); + $('div.progress__label', this.element).html(label); + if (this.updateCallback) { + this.updateCallback(percentage, message, this); + } + }, + + /** + * Start monitoring progress via Ajax. + * + * @param {string} uri + * The URI to use for monitoring. + * @param {number} delay + * The delay for calling the monitoring URI. + */ + startMonitoring: function (uri, delay) { + this.delay = delay; + this.uri = uri; + this.sendPing(); + }, + + /** + * Stop monitoring progress via Ajax. + */ + stopMonitoring: function () { + clearTimeout(this.timer); + // This allows monitoring to be stopped from within the callback. + this.uri = null; + }, + + /** + * Request progress data from server. + */ + sendPing: function () { + if (this.timer) { + clearTimeout(this.timer); + } + if (this.uri) { + var pb = this; + // When doing a post request, you need non-null data. Otherwise a + // HTTP 411 or HTTP 406 (with Apache mod_security) error may result. + var uri = this.uri; + if (uri.indexOf('?') === -1) { + uri += '?'; + } + else { + uri += '&'; + } + uri += '_format=json'; + $.ajax({ + type: this.method, + url: uri, + data: '', + dataType: 'json', + success: function (progress) { + // Display errors. + if (progress.status === 0) { + pb.displayError(progress.data); + return; + } + // Update display. + pb.setProgress(progress.percentage, progress.message, progress.label); + // Schedule next timer. + pb.timer = setTimeout(function () { pb.sendPing(); }, pb.delay); + }, + error: function (xmlhttp) { + var e = new Drupal.AjaxError(xmlhttp, pb.uri); + pb.displayError('<pre>' + e.message + '</pre>'); + } + }); + } + }, + + /** + * Display errors on the page. + * + * @param {string} string + * The error message to show the user. + */ + displayError: function (string) { + var error = $('<div class="messages messages--error"></div>').html(string); + $(this.element).before(error).hide(); + + if (this.errorCallback) { + this.errorCallback(this); + } + } + }); + +})(jQuery, Drupal); diff --git a/core/misc/progress.js b/core/misc/progress.js index a6694892b270..bcba9c396bf3 100644 --- a/core/misc/progress.js +++ b/core/misc/progress.js @@ -1,78 +1,30 @@ /** - * @file - * Progress bar. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/progress.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Theme function for the progress bar. - * - * @param {string} id - * The id for the progress bar. - * - * @return {string} - * The HTML for the progress bar. - */ Drupal.theme.progressBar = function (id) { - return '<div id="' + id + '" class="progress" aria-live="polite">' + - '<div class="progress__label"> </div>' + - '<div class="progress__track"><div class="progress__bar"></div></div>' + - '<div class="progress__percentage"></div>' + - '<div class="progress__description"> </div>' + - '</div>'; + return '<div id="' + id + '" class="progress" aria-live="polite">' + '<div class="progress__label"> </div>' + '<div class="progress__track"><div class="progress__bar"></div></div>' + '<div class="progress__percentage"></div>' + '<div class="progress__description"> </div>' + '</div>'; }; - /** - * A progressbar object. Initialized with the given id. Must be inserted into - * the DOM afterwards through progressBar.element. - * - * Method is the function which will perform the HTTP request to get the - * progress bar state. Either "GET" or "POST". - * - * @example - * pb = new Drupal.ProgressBar('myProgressBar'); - * some_element.appendChild(pb.element); - * - * @constructor - * - * @param {string} id - * The id for the progressbar. - * @param {function} updateCallback - * Callback to run on update. - * @param {string} method - * HTTP method to use. - * @param {function} errorCallback - * Callback to call on error. - */ Drupal.ProgressBar = function (id, updateCallback, method, errorCallback) { this.id = id; this.method = method || 'GET'; this.updateCallback = updateCallback; this.errorCallback = errorCallback; - // The WAI-ARIA setting aria-live="polite" will announce changes after - // users - // have completed their current activity and not interrupt the screen - // reader. this.element = $(Drupal.theme('progressBar', id)); }; - $.extend(Drupal.ProgressBar.prototype, /** @lends Drupal.ProgressBar# */{ - - /** - * Set the percentage and status message for the progressbar. - * - * @param {number} percentage - * The progress percentage. - * @param {string} message - * The message to show the user. - * @param {string} label - * The text for the progressbar label. - */ - setProgress: function (percentage, message, label) { + $.extend(Drupal.ProgressBar.prototype, { + setProgress: function setProgress(percentage, message, label) { if (percentage >= 0 && percentage <= 100) { $(this.element).find('div.progress__bar').css('width', percentage + '%'); $(this.element).find('div.progress__percentage').html(percentage + '%'); @@ -84,45 +36,29 @@ } }, - /** - * Start monitoring progress via Ajax. - * - * @param {string} uri - * The URI to use for monitoring. - * @param {number} delay - * The delay for calling the monitoring URI. - */ - startMonitoring: function (uri, delay) { + startMonitoring: function startMonitoring(uri, delay) { this.delay = delay; this.uri = uri; this.sendPing(); }, - /** - * Stop monitoring progress via Ajax. - */ - stopMonitoring: function () { + stopMonitoring: function stopMonitoring() { clearTimeout(this.timer); - // This allows monitoring to be stopped from within the callback. + this.uri = null; }, - /** - * Request progress data from server. - */ - sendPing: function () { + sendPing: function sendPing() { if (this.timer) { clearTimeout(this.timer); } if (this.uri) { var pb = this; - // When doing a post request, you need non-null data. Otherwise a - // HTTP 411 or HTTP 406 (with Apache mod_security) error may result. + var uri = this.uri; if (uri.indexOf('?') === -1) { uri += '?'; - } - else { + } else { uri += '&'; } uri += '_format=json'; @@ -131,18 +67,19 @@ url: uri, data: '', dataType: 'json', - success: function (progress) { - // Display errors. + success: function success(progress) { if (progress.status === 0) { pb.displayError(progress.data); return; } - // Update display. + pb.setProgress(progress.percentage, progress.message, progress.label); - // Schedule next timer. - pb.timer = setTimeout(function () { pb.sendPing(); }, pb.delay); + + pb.timer = setTimeout(function () { + pb.sendPing(); + }, pb.delay); }, - error: function (xmlhttp) { + error: function error(xmlhttp) { var e = new Drupal.AjaxError(xmlhttp, pb.uri); pb.displayError('<pre>' + e.message + '</pre>'); } @@ -150,13 +87,7 @@ } }, - /** - * Display errors on the page. - * - * @param {string} string - * The error message to show the user. - */ - displayError: function (string) { + displayError: function displayError(string) { var error = $('<div class="messages messages--error"></div>').html(string); $(this.element).before(error).hide(); @@ -165,5 +96,4 @@ } } }); - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/states.es6.js b/core/misc/states.es6.js new file mode 100644 index 000000000000..24374b625f7a --- /dev/null +++ b/core/misc/states.es6.js @@ -0,0 +1,724 @@ +/** + * @file + * Drupal's states library. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * The base States namespace. + * + * Having the local states variable allows us to use the States namespace + * without having to always declare "Drupal.states". + * + * @namespace Drupal.states + */ + var states = Drupal.states = { + + /** + * An array of functions that should be postponed. + */ + postponed: [] + }; + + /** + * Attaches the states. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches states behaviors. + */ + Drupal.behaviors.states = { + attach: function (context, settings) { + var $states = $(context).find('[data-drupal-states]'); + var config; + var state; + var il = $states.length; + for (var i = 0; i < il; i++) { + config = JSON.parse($states[i].getAttribute('data-drupal-states')); + for (state in config) { + if (config.hasOwnProperty(state)) { + new states.Dependent({ + element: $($states[i]), + state: states.State.sanitize(state), + constraints: config[state] + }); + } + } + } + + // Execute all postponed functions now. + while (states.postponed.length) { + (states.postponed.shift())(); + } + } + }; + + /** + * Object representing an element that depends on other elements. + * + * @constructor Drupal.states.Dependent + * + * @param {object} args + * Object with the following keys (all of which are required) + * @param {jQuery} args.element + * A jQuery object of the dependent element + * @param {Drupal.states.State} args.state + * A State object describing the state that is dependent + * @param {object} args.constraints + * An object with dependency specifications. Lists all elements that this + * element depends on. It can be nested and can contain + * arbitrary AND and OR clauses. + */ + states.Dependent = function (args) { + $.extend(this, {values: {}, oldValue: null}, args); + + this.dependees = this.getDependees(); + for (var selector in this.dependees) { + if (this.dependees.hasOwnProperty(selector)) { + this.initializeDependee(selector, this.dependees[selector]); + } + } + }; + + /** + * Comparison functions for comparing the value of an element with the + * specification from the dependency settings. If the object type can't be + * found in this list, the === operator is used by default. + * + * @name Drupal.states.Dependent.comparisons + * + * @prop {function} RegExp + * @prop {function} Function + * @prop {function} Number + */ + states.Dependent.comparisons = { + RegExp: function (reference, value) { + return reference.test(value); + }, + Function: function (reference, value) { + // The "reference" variable is a comparison function. + return reference(value); + }, + Number: function (reference, value) { + // If "reference" is a number and "value" is a string, then cast + // reference as a string before applying the strict comparison in + // compare(). + // Otherwise numeric keys in the form's #states array fail to match + // string values returned from jQuery's val(). + return (typeof value === 'string') ? compare(reference.toString(), value) : compare(reference, value); + } + }; + + states.Dependent.prototype = { + + /** + * Initializes one of the elements this dependent depends on. + * + * @memberof Drupal.states.Dependent# + * + * @param {string} selector + * The CSS selector describing the dependee. + * @param {object} dependeeStates + * The list of states that have to be monitored for tracking the + * dependee's compliance status. + */ + initializeDependee: function (selector, dependeeStates) { + var state; + var self = this; + + function stateEventHandler(e) { + self.update(e.data.selector, e.data.state, e.value); + } + + // Cache for the states of this dependee. + this.values[selector] = {}; + + for (var i in dependeeStates) { + if (dependeeStates.hasOwnProperty(i)) { + state = dependeeStates[i]; + // Make sure we're not initializing this selector/state combination + // twice. + if ($.inArray(state, dependeeStates) === -1) { + continue; + } + + state = states.State.sanitize(state); + + // Initialize the value of this state. + this.values[selector][state.name] = null; + + // Monitor state changes of the specified state for this dependee. + $(selector).on('state:' + state, {selector: selector, state: state}, stateEventHandler); + + // Make sure the event we just bound ourselves to is actually fired. + new states.Trigger({selector: selector, state: state}); + } + } + }, + + /** + * Compares a value with a reference value. + * + * @memberof Drupal.states.Dependent# + * + * @param {object} reference + * The value used for reference. + * @param {string} selector + * CSS selector describing the dependee. + * @param {Drupal.states.State} state + * A State object describing the dependee's updated state. + * + * @return {bool} + * true or false. + */ + compare: function (reference, selector, state) { + var value = this.values[selector][state.name]; + if (reference.constructor.name in states.Dependent.comparisons) { + // Use a custom compare function for certain reference value types. + return states.Dependent.comparisons[reference.constructor.name](reference, value); + } + else { + // Do a plain comparison otherwise. + return compare(reference, value); + } + }, + + /** + * Update the value of a dependee's state. + * + * @memberof Drupal.states.Dependent# + * + * @param {string} selector + * CSS selector describing the dependee. + * @param {Drupal.states.state} state + * A State object describing the dependee's updated state. + * @param {string} value + * The new value for the dependee's updated state. + */ + update: function (selector, state, value) { + // Only act when the 'new' value is actually new. + if (value !== this.values[selector][state.name]) { + this.values[selector][state.name] = value; + this.reevaluate(); + } + }, + + /** + * Triggers change events in case a state changed. + * + * @memberof Drupal.states.Dependent# + */ + reevaluate: function () { + // Check whether any constraint for this dependent state is satisfied. + var value = this.verifyConstraints(this.constraints); + + // Only invoke a state change event when the value actually changed. + if (value !== this.oldValue) { + // Store the new value so that we can compare later whether the value + // actually changed. + this.oldValue = value; + + // Normalize the value to match the normalized state name. + value = invert(value, this.state.invert); + + // By adding "trigger: true", we ensure that state changes don't go into + // infinite loops. + this.element.trigger({type: 'state:' + this.state, value: value, trigger: true}); + } + }, + + /** + * Evaluates child constraints to determine if a constraint is satisfied. + * + * @memberof Drupal.states.Dependent# + * + * @param {object|Array} constraints + * A constraint object or an array of constraints. + * @param {string} selector + * The selector for these constraints. If undefined, there isn't yet a + * selector that these constraints apply to. In that case, the keys of the + * object are interpreted as the selector if encountered. + * + * @return {bool} + * true or false, depending on whether these constraints are satisfied. + */ + verifyConstraints: function (constraints, selector) { + var result; + if ($.isArray(constraints)) { + // This constraint is an array (OR or XOR). + var hasXor = $.inArray('xor', constraints) === -1; + var len = constraints.length; + for (var i = 0; i < len; i++) { + if (constraints[i] !== 'xor') { + var constraint = this.checkConstraints(constraints[i], selector, i); + // Return if this is OR and we have a satisfied constraint or if + // this is XOR and we have a second satisfied constraint. + if (constraint && (hasXor || result)) { + return hasXor; + } + result = result || constraint; + } + } + } + // Make sure we don't try to iterate over things other than objects. This + // shouldn't normally occur, but in case the condition definition is + // bogus, we don't want to end up with an infinite loop. + else if ($.isPlainObject(constraints)) { + // This constraint is an object (AND). + for (var n in constraints) { + if (constraints.hasOwnProperty(n)) { + result = ternary(result, this.checkConstraints(constraints[n], selector, n)); + // False and anything else will evaluate to false, so return when + // any false condition is found. + if (result === false) { return false; } + } + } + } + return result; + }, + + /** + * Checks whether the value matches the requirements for this constraint. + * + * @memberof Drupal.states.Dependent# + * + * @param {string|Array|object} value + * Either the value of a state or an array/object of constraints. In the + * latter case, resolving the constraint continues. + * @param {string} [selector] + * The selector for this constraint. If undefined, there isn't yet a + * selector that this constraint applies to. In that case, the state key + * is propagates to a selector and resolving continues. + * @param {Drupal.states.State} [state] + * The state to check for this constraint. If undefined, resolving + * continues. If both selector and state aren't undefined and valid + * non-numeric strings, a lookup for the actual value of that selector's + * state is performed. This parameter is not a State object but a pristine + * state string. + * + * @return {bool} + * true or false, depending on whether this constraint is satisfied. + */ + checkConstraints: function (value, selector, state) { + // Normalize the last parameter. If it's non-numeric, we treat it either + // as a selector (in case there isn't one yet) or as a trigger/state. + if (typeof state !== 'string' || (/[0-9]/).test(state[0])) { + state = null; + } + else if (typeof selector === 'undefined') { + // Propagate the state to the selector when there isn't one yet. + selector = state; + state = null; + } + + if (state !== null) { + // Constraints is the actual constraints of an element to check for. + state = states.State.sanitize(state); + return invert(this.compare(value, selector, state), state.invert); + } + else { + // Resolve this constraint as an AND/OR operator. + return this.verifyConstraints(value, selector); + } + }, + + /** + * Gathers information about all required triggers. + * + * @memberof Drupal.states.Dependent# + * + * @return {object} + * An object describing the required triggers. + */ + getDependees: function () { + var cache = {}; + // Swivel the lookup function so that we can record all available + // selector- state combinations for initialization. + var _compare = this.compare; + this.compare = function (reference, selector, state) { + (cache[selector] || (cache[selector] = [])).push(state.name); + // Return nothing (=== undefined) so that the constraint loops are not + // broken. + }; + + // This call doesn't actually verify anything but uses the resolving + // mechanism to go through the constraints array, trying to look up each + // value. Since we swivelled the compare function, this comparison returns + // undefined and lookup continues until the very end. Instead of lookup up + // the value, we record that combination of selector and state so that we + // can initialize all triggers. + this.verifyConstraints(this.constraints); + // Restore the original function. + this.compare = _compare; + + return cache; + } + }; + + /** + * @constructor Drupal.states.Trigger + * + * @param {object} args + * Trigger arguments. + */ + states.Trigger = function (args) { + $.extend(this, args); + + if (this.state in states.Trigger.states) { + this.element = $(this.selector); + + // Only call the trigger initializer when it wasn't yet attached to this + // element. Otherwise we'd end up with duplicate events. + if (!this.element.data('trigger:' + this.state)) { + this.initialize(); + } + } + }; + + states.Trigger.prototype = { + + /** + * @memberof Drupal.states.Trigger# + */ + initialize: function () { + var trigger = states.Trigger.states[this.state]; + + if (typeof trigger === 'function') { + // We have a custom trigger initialization function. + trigger.call(window, this.element); + } + else { + for (var event in trigger) { + if (trigger.hasOwnProperty(event)) { + this.defaultTrigger(event, trigger[event]); + } + } + } + + // Mark this trigger as initialized for this element. + this.element.data('trigger:' + this.state, true); + }, + + /** + * @memberof Drupal.states.Trigger# + * + * @param {jQuery.Event} event + * The event triggered. + * @param {function} valueFn + * The function to call. + */ + defaultTrigger: function (event, valueFn) { + var oldValue = valueFn.call(this.element); + + // Attach the event callback. + this.element.on(event, $.proxy(function (e) { + var value = valueFn.call(this.element, e); + // Only trigger the event if the value has actually changed. + if (oldValue !== value) { + this.element.trigger({type: 'state:' + this.state, value: value, oldValue: oldValue}); + oldValue = value; + } + }, this)); + + states.postponed.push($.proxy(function () { + // Trigger the event once for initialization purposes. + this.element.trigger({type: 'state:' + this.state, value: oldValue, oldValue: null}); + }, this)); + } + }; + + /** + * This list of states contains functions that are used to monitor the state + * of an element. Whenever an element depends on the state of another element, + * one of these trigger functions is added to the dependee so that the + * dependent element can be updated. + * + * @name Drupal.states.Trigger.states + * + * @prop empty + * @prop checked + * @prop value + * @prop collapsed + */ + states.Trigger.states = { + // 'empty' describes the state to be monitored. + empty: { + // 'keyup' is the (native DOM) event that we watch for. + keyup: function () { + // The function associated with that trigger returns the new value for + // the state. + return this.val() === ''; + } + }, + + checked: { + change: function () { + // prop() and attr() only takes the first element into account. To + // support selectors matching multiple checkboxes, iterate over all and + // return whether any is checked. + var checked = false; + this.each(function () { + // Use prop() here as we want a boolean of the checkbox state. + // @see http://api.jquery.com/prop/ + checked = $(this).prop('checked'); + // Break the each() loop if this is checked. + return !checked; + }); + return checked; + } + }, + + // For radio buttons, only return the value if the radio button is selected. + value: { + keyup: function () { + // Radio buttons share the same :input[name="key"] selector. + if (this.length > 1) { + // Initial checked value of radios is undefined, so we return false. + return this.filter(':checked').val() || false; + } + return this.val(); + }, + change: function () { + // Radio buttons share the same :input[name="key"] selector. + if (this.length > 1) { + // Initial checked value of radios is undefined, so we return false. + return this.filter(':checked').val() || false; + } + return this.val(); + } + }, + + collapsed: { + collapsed: function (e) { + return (typeof e !== 'undefined' && 'value' in e) ? e.value : !this.is('[open]'); + } + } + }; + + /** + * A state object is used for describing the state and performing aliasing. + * + * @constructor Drupal.states.State + * + * @param {string} state + * The name of the state. + */ + states.State = function (state) { + + /** + * Original unresolved name. + */ + this.pristine = this.name = state; + + // Normalize the state name. + var process = true; + do { + // Iteratively remove exclamation marks and invert the value. + while (this.name.charAt(0) === '!') { + this.name = this.name.substring(1); + this.invert = !this.invert; + } + + // Replace the state with its normalized name. + if (this.name in states.State.aliases) { + this.name = states.State.aliases[this.name]; + } + else { + process = false; + } + } while (process); + }; + + /** + * Creates a new State object by sanitizing the passed value. + * + * @name Drupal.states.State.sanitize + * + * @param {string|Drupal.states.State} state + * A state object or the name of a state. + * + * @return {Drupal.states.state} + * A state object. + */ + states.State.sanitize = function (state) { + if (state instanceof states.State) { + return state; + } + else { + return new states.State(state); + } + }; + + /** + * This list of aliases is used to normalize states and associates negated + * names with their respective inverse state. + * + * @name Drupal.states.State.aliases + */ + states.State.aliases = { + enabled: '!disabled', + invisible: '!visible', + invalid: '!valid', + untouched: '!touched', + optional: '!required', + filled: '!empty', + unchecked: '!checked', + irrelevant: '!relevant', + expanded: '!collapsed', + open: '!collapsed', + closed: 'collapsed', + readwrite: '!readonly' + }; + + states.State.prototype = { + + /** + * @memberof Drupal.states.State# + */ + invert: false, + + /** + * Ensures that just using the state object returns the name. + * + * @memberof Drupal.states.State# + * + * @return {string} + * The name of the state. + */ + toString: function () { + return this.name; + } + }; + + /** + * Global state change handlers. These are bound to "document" to cover all + * elements whose state changes. Events sent to elements within the page + * bubble up to these handlers. We use this system so that themes and modules + * can override these state change handlers for particular parts of a page. + */ + + var $document = $(document); + $document.on('state:disabled', function (e) { + // Only act when this change was triggered by a dependency and not by the + // element monitoring itself. + if (e.trigger) { + $(e.target) + .prop('disabled', e.value) + .closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggleClass('form-disabled', e.value) + .find('select, input, textarea').prop('disabled', e.value); + + // Note: WebKit nightlies don't reflect that change correctly. + // See https://bugs.webkit.org/show_bug.cgi?id=23789 + } + }); + + $document.on('state:required', function (e) { + if (e.trigger) { + if (e.value) { + var label = 'label' + (e.target.id ? '[for=' + e.target.id + ']' : ''); + var $label = $(e.target).attr({'required': 'required', 'aria-required': 'aria-required'}).closest('.js-form-item, .js-form-wrapper').find(label); + // Avoids duplicate required markers on initialization. + if (!$label.hasClass('js-form-required').length) { + $label.addClass('js-form-required form-required'); + } + } + else { + $(e.target).removeAttr('required aria-required').closest('.js-form-item, .js-form-wrapper').find('label.js-form-required').removeClass('js-form-required form-required'); + } + } + }); + + $document.on('state:visible', function (e) { + if (e.trigger) { + $(e.target).closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggle(e.value); + } + }); + + $document.on('state:checked', function (e) { + if (e.trigger) { + $(e.target).prop('checked', e.value); + } + }); + + $document.on('state:collapsed', function (e) { + if (e.trigger) { + if ($(e.target).is('[open]') === e.value) { + $(e.target).find('> summary').trigger('click'); + } + } + }); + + /** + * These are helper functions implementing addition "operators" and don't + * implement any logic that is particular to states. + */ + + /** + * Bitwise AND with a third undefined state. + * + * @function Drupal.states~ternary + * + * @param {*} a + * Value a. + * @param {*} b + * Value b + * + * @return {bool} + * The result. + */ + function ternary(a, b) { + if (typeof a === 'undefined') { + return b; + } + else if (typeof b === 'undefined') { + return a; + } + else { + return a && b; + } + } + + /** + * Inverts a (if it's not undefined) when invertState is true. + * + * @function Drupal.states~invert + * + * @param {*} a + * The value to maybe invert. + * @param {bool} invertState + * Whether to invert state or not. + * + * @return {bool} + * The result. + */ + function invert(a, invertState) { + return (invertState && typeof a !== 'undefined') ? !a : a; + } + + /** + * Compares two values while ignoring undefined values. + * + * @function Drupal.states~compare + * + * @param {*} a + * Value a. + * @param {*} b + * Value b. + * + * @return {bool} + * The comparison result. + */ + function compare(a, b) { + if (a === b) { + return typeof a === 'undefined' ? a : true; + } + else { + return typeof a === 'undefined' || typeof b === 'undefined'; + } + } + +})(jQuery, Drupal); diff --git a/core/misc/states.js b/core/misc/states.js index 24374b625f7a..0354df4b4027 100644 --- a/core/misc/states.js +++ b/core/misc/states.js @@ -1,38 +1,21 @@ /** - * @file - * Drupal's states library. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/states.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * The base States namespace. - * - * Having the local states variable allows us to use the States namespace - * without having to always declare "Drupal.states". - * - * @namespace Drupal.states - */ var states = Drupal.states = { - - /** - * An array of functions that should be postponed. - */ postponed: [] }; - /** - * Attaches the states. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches states behaviors. - */ Drupal.behaviors.states = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $states = $(context).find('[data-drupal-states]'); var config; var state; @@ -50,31 +33,14 @@ } } - // Execute all postponed functions now. while (states.postponed.length) { - (states.postponed.shift())(); + states.postponed.shift()(); } } }; - /** - * Object representing an element that depends on other elements. - * - * @constructor Drupal.states.Dependent - * - * @param {object} args - * Object with the following keys (all of which are required) - * @param {jQuery} args.element - * A jQuery object of the dependent element - * @param {Drupal.states.State} args.state - * A State object describing the state that is dependent - * @param {object} args.constraints - * An object with dependency specifications. Lists all elements that this - * element depends on. It can be nested and can contain - * arbitrary AND and OR clauses. - */ states.Dependent = function (args) { - $.extend(this, {values: {}, oldValue: null}, args); + $.extend(this, { values: {}, oldValue: null }, args); this.dependees = this.getDependees(); for (var selector in this.dependees) { @@ -84,49 +50,20 @@ } }; - /** - * Comparison functions for comparing the value of an element with the - * specification from the dependency settings. If the object type can't be - * found in this list, the === operator is used by default. - * - * @name Drupal.states.Dependent.comparisons - * - * @prop {function} RegExp - * @prop {function} Function - * @prop {function} Number - */ states.Dependent.comparisons = { - RegExp: function (reference, value) { + RegExp: function RegExp(reference, value) { return reference.test(value); }, - Function: function (reference, value) { - // The "reference" variable is a comparison function. + Function: function Function(reference, value) { return reference(value); }, - Number: function (reference, value) { - // If "reference" is a number and "value" is a string, then cast - // reference as a string before applying the strict comparison in - // compare(). - // Otherwise numeric keys in the form's #states array fail to match - // string values returned from jQuery's val(). - return (typeof value === 'string') ? compare(reference.toString(), value) : compare(reference, value); + Number: function Number(reference, value) { + return typeof value === 'string' ? _compare2(reference.toString(), value) : _compare2(reference, value); } }; states.Dependent.prototype = { - - /** - * Initializes one of the elements this dependent depends on. - * - * @memberof Drupal.states.Dependent# - * - * @param {string} selector - * The CSS selector describing the dependee. - * @param {object} dependeeStates - * The list of states that have to be monitored for tracking the - * dependee's compliance status. - */ - initializeDependee: function (selector, dependeeStates) { + initializeDependee: function initializeDependee(selector, dependeeStates) { var state; var self = this; @@ -134,245 +71,122 @@ self.update(e.data.selector, e.data.state, e.value); } - // Cache for the states of this dependee. this.values[selector] = {}; for (var i in dependeeStates) { if (dependeeStates.hasOwnProperty(i)) { state = dependeeStates[i]; - // Make sure we're not initializing this selector/state combination - // twice. + if ($.inArray(state, dependeeStates) === -1) { continue; } state = states.State.sanitize(state); - // Initialize the value of this state. this.values[selector][state.name] = null; - // Monitor state changes of the specified state for this dependee. - $(selector).on('state:' + state, {selector: selector, state: state}, stateEventHandler); + $(selector).on('state:' + state, { selector: selector, state: state }, stateEventHandler); - // Make sure the event we just bound ourselves to is actually fired. - new states.Trigger({selector: selector, state: state}); + new states.Trigger({ selector: selector, state: state }); } } }, - /** - * Compares a value with a reference value. - * - * @memberof Drupal.states.Dependent# - * - * @param {object} reference - * The value used for reference. - * @param {string} selector - * CSS selector describing the dependee. - * @param {Drupal.states.State} state - * A State object describing the dependee's updated state. - * - * @return {bool} - * true or false. - */ - compare: function (reference, selector, state) { + compare: function compare(reference, selector, state) { var value = this.values[selector][state.name]; if (reference.constructor.name in states.Dependent.comparisons) { - // Use a custom compare function for certain reference value types. return states.Dependent.comparisons[reference.constructor.name](reference, value); - } - else { - // Do a plain comparison otherwise. - return compare(reference, value); + } else { + return _compare2(reference, value); } }, - /** - * Update the value of a dependee's state. - * - * @memberof Drupal.states.Dependent# - * - * @param {string} selector - * CSS selector describing the dependee. - * @param {Drupal.states.state} state - * A State object describing the dependee's updated state. - * @param {string} value - * The new value for the dependee's updated state. - */ - update: function (selector, state, value) { - // Only act when the 'new' value is actually new. + update: function update(selector, state, value) { if (value !== this.values[selector][state.name]) { this.values[selector][state.name] = value; this.reevaluate(); } }, - /** - * Triggers change events in case a state changed. - * - * @memberof Drupal.states.Dependent# - */ - reevaluate: function () { - // Check whether any constraint for this dependent state is satisfied. + reevaluate: function reevaluate() { var value = this.verifyConstraints(this.constraints); - // Only invoke a state change event when the value actually changed. if (value !== this.oldValue) { - // Store the new value so that we can compare later whether the value - // actually changed. this.oldValue = value; - // Normalize the value to match the normalized state name. value = invert(value, this.state.invert); - // By adding "trigger: true", we ensure that state changes don't go into - // infinite loops. - this.element.trigger({type: 'state:' + this.state, value: value, trigger: true}); + this.element.trigger({ type: 'state:' + this.state, value: value, trigger: true }); } }, - /** - * Evaluates child constraints to determine if a constraint is satisfied. - * - * @memberof Drupal.states.Dependent# - * - * @param {object|Array} constraints - * A constraint object or an array of constraints. - * @param {string} selector - * The selector for these constraints. If undefined, there isn't yet a - * selector that these constraints apply to. In that case, the keys of the - * object are interpreted as the selector if encountered. - * - * @return {bool} - * true or false, depending on whether these constraints are satisfied. - */ - verifyConstraints: function (constraints, selector) { + verifyConstraints: function verifyConstraints(constraints, selector) { var result; if ($.isArray(constraints)) { - // This constraint is an array (OR or XOR). var hasXor = $.inArray('xor', constraints) === -1; var len = constraints.length; for (var i = 0; i < len; i++) { if (constraints[i] !== 'xor') { var constraint = this.checkConstraints(constraints[i], selector, i); - // Return if this is OR and we have a satisfied constraint or if - // this is XOR and we have a second satisfied constraint. + if (constraint && (hasXor || result)) { return hasXor; } result = result || constraint; } } - } - // Make sure we don't try to iterate over things other than objects. This - // shouldn't normally occur, but in case the condition definition is - // bogus, we don't want to end up with an infinite loop. - else if ($.isPlainObject(constraints)) { - // This constraint is an object (AND). - for (var n in constraints) { - if (constraints.hasOwnProperty(n)) { - result = ternary(result, this.checkConstraints(constraints[n], selector, n)); - // False and anything else will evaluate to false, so return when - // any false condition is found. - if (result === false) { return false; } + } else if ($.isPlainObject(constraints)) { + for (var n in constraints) { + if (constraints.hasOwnProperty(n)) { + result = ternary(result, this.checkConstraints(constraints[n], selector, n)); + + if (result === false) { + return false; + } + } } } - } return result; }, - /** - * Checks whether the value matches the requirements for this constraint. - * - * @memberof Drupal.states.Dependent# - * - * @param {string|Array|object} value - * Either the value of a state or an array/object of constraints. In the - * latter case, resolving the constraint continues. - * @param {string} [selector] - * The selector for this constraint. If undefined, there isn't yet a - * selector that this constraint applies to. In that case, the state key - * is propagates to a selector and resolving continues. - * @param {Drupal.states.State} [state] - * The state to check for this constraint. If undefined, resolving - * continues. If both selector and state aren't undefined and valid - * non-numeric strings, a lookup for the actual value of that selector's - * state is performed. This parameter is not a State object but a pristine - * state string. - * - * @return {bool} - * true or false, depending on whether this constraint is satisfied. - */ - checkConstraints: function (value, selector, state) { - // Normalize the last parameter. If it's non-numeric, we treat it either - // as a selector (in case there isn't one yet) or as a trigger/state. - if (typeof state !== 'string' || (/[0-9]/).test(state[0])) { + checkConstraints: function checkConstraints(value, selector, state) { + if (typeof state !== 'string' || /[0-9]/.test(state[0])) { state = null; - } - else if (typeof selector === 'undefined') { - // Propagate the state to the selector when there isn't one yet. + } else if (typeof selector === 'undefined') { selector = state; state = null; } if (state !== null) { - // Constraints is the actual constraints of an element to check for. state = states.State.sanitize(state); return invert(this.compare(value, selector, state), state.invert); - } - else { - // Resolve this constraint as an AND/OR operator. + } else { return this.verifyConstraints(value, selector); } }, - /** - * Gathers information about all required triggers. - * - * @memberof Drupal.states.Dependent# - * - * @return {object} - * An object describing the required triggers. - */ - getDependees: function () { + getDependees: function getDependees() { var cache = {}; - // Swivel the lookup function so that we can record all available - // selector- state combinations for initialization. + var _compare = this.compare; this.compare = function (reference, selector, state) { (cache[selector] || (cache[selector] = [])).push(state.name); - // Return nothing (=== undefined) so that the constraint loops are not - // broken. }; - // This call doesn't actually verify anything but uses the resolving - // mechanism to go through the constraints array, trying to look up each - // value. Since we swivelled the compare function, this comparison returns - // undefined and lookup continues until the very end. Instead of lookup up - // the value, we record that combination of selector and state so that we - // can initialize all triggers. this.verifyConstraints(this.constraints); - // Restore the original function. + this.compare = _compare; return cache; } }; - /** - * @constructor Drupal.states.Trigger - * - * @param {object} args - * Trigger arguments. - */ states.Trigger = function (args) { $.extend(this, args); if (this.state in states.Trigger.states) { this.element = $(this.selector); - // Only call the trigger initializer when it wasn't yet attached to this - // element. Otherwise we'd end up with duplicate events. if (!this.element.data('trigger:' + this.state)) { this.initialize(); } @@ -380,18 +194,12 @@ }; states.Trigger.prototype = { - - /** - * @memberof Drupal.states.Trigger# - */ - initialize: function () { + initialize: function initialize() { var trigger = states.Trigger.states[this.state]; if (typeof trigger === 'function') { - // We have a custom trigger initialization function. trigger.call(window, this.element); - } - else { + } else { for (var event in trigger) { if (trigger.hasOwnProperty(event)) { this.defaultTrigger(event, trigger[event]); @@ -399,93 +207,55 @@ } } - // Mark this trigger as initialized for this element. this.element.data('trigger:' + this.state, true); }, - /** - * @memberof Drupal.states.Trigger# - * - * @param {jQuery.Event} event - * The event triggered. - * @param {function} valueFn - * The function to call. - */ - defaultTrigger: function (event, valueFn) { + defaultTrigger: function defaultTrigger(event, valueFn) { var oldValue = valueFn.call(this.element); - // Attach the event callback. this.element.on(event, $.proxy(function (e) { var value = valueFn.call(this.element, e); - // Only trigger the event if the value has actually changed. + if (oldValue !== value) { - this.element.trigger({type: 'state:' + this.state, value: value, oldValue: oldValue}); + this.element.trigger({ type: 'state:' + this.state, value: value, oldValue: oldValue }); oldValue = value; } }, this)); states.postponed.push($.proxy(function () { - // Trigger the event once for initialization purposes. - this.element.trigger({type: 'state:' + this.state, value: oldValue, oldValue: null}); + this.element.trigger({ type: 'state:' + this.state, value: oldValue, oldValue: null }); }, this)); } }; - /** - * This list of states contains functions that are used to monitor the state - * of an element. Whenever an element depends on the state of another element, - * one of these trigger functions is added to the dependee so that the - * dependent element can be updated. - * - * @name Drupal.states.Trigger.states - * - * @prop empty - * @prop checked - * @prop value - * @prop collapsed - */ states.Trigger.states = { - // 'empty' describes the state to be monitored. empty: { - // 'keyup' is the (native DOM) event that we watch for. - keyup: function () { - // The function associated with that trigger returns the new value for - // the state. + keyup: function keyup() { return this.val() === ''; } }, checked: { - change: function () { - // prop() and attr() only takes the first element into account. To - // support selectors matching multiple checkboxes, iterate over all and - // return whether any is checked. + change: function change() { var checked = false; this.each(function () { - // Use prop() here as we want a boolean of the checkbox state. - // @see http://api.jquery.com/prop/ checked = $(this).prop('checked'); - // Break the each() loop if this is checked. + return !checked; }); return checked; } }, - // For radio buttons, only return the value if the radio button is selected. value: { - keyup: function () { - // Radio buttons share the same :input[name="key"] selector. + keyup: function keyup() { if (this.length > 1) { - // Initial checked value of radios is undefined, so we return false. return this.filter(':checked').val() || false; } return this.val(); }, - change: function () { - // Radio buttons share the same :input[name="key"] selector. + change: function change() { if (this.length > 1) { - // Initial checked value of radios is undefined, so we return false. return this.filter(':checked').val() || false; } return this.val(); @@ -493,72 +263,38 @@ }, collapsed: { - collapsed: function (e) { - return (typeof e !== 'undefined' && 'value' in e) ? e.value : !this.is('[open]'); + collapsed: function collapsed(e) { + return typeof e !== 'undefined' && 'value' in e ? e.value : !this.is('[open]'); } } }; - /** - * A state object is used for describing the state and performing aliasing. - * - * @constructor Drupal.states.State - * - * @param {string} state - * The name of the state. - */ states.State = function (state) { - - /** - * Original unresolved name. - */ this.pristine = this.name = state; - // Normalize the state name. var process = true; do { - // Iteratively remove exclamation marks and invert the value. while (this.name.charAt(0) === '!') { this.name = this.name.substring(1); this.invert = !this.invert; } - // Replace the state with its normalized name. if (this.name in states.State.aliases) { this.name = states.State.aliases[this.name]; - } - else { + } else { process = false; } } while (process); }; - /** - * Creates a new State object by sanitizing the passed value. - * - * @name Drupal.states.State.sanitize - * - * @param {string|Drupal.states.State} state - * A state object or the name of a state. - * - * @return {Drupal.states.state} - * A state object. - */ states.State.sanitize = function (state) { if (state instanceof states.State) { return state; - } - else { + } else { return new states.State(state); } }; - /** - * This list of aliases is used to normalize states and associates negated - * names with their respective inverse state. - * - * @name Drupal.states.State.aliases - */ states.State.aliases = { enabled: '!disabled', invisible: '!visible', @@ -575,44 +311,17 @@ }; states.State.prototype = { - - /** - * @memberof Drupal.states.State# - */ invert: false, - /** - * Ensures that just using the state object returns the name. - * - * @memberof Drupal.states.State# - * - * @return {string} - * The name of the state. - */ - toString: function () { + toString: function toString() { return this.name; } }; - /** - * Global state change handlers. These are bound to "document" to cover all - * elements whose state changes. Events sent to elements within the page - * bubble up to these handlers. We use this system so that themes and modules - * can override these state change handlers for particular parts of a page. - */ - var $document = $(document); $document.on('state:disabled', function (e) { - // Only act when this change was triggered by a dependency and not by the - // element monitoring itself. if (e.trigger) { - $(e.target) - .prop('disabled', e.value) - .closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggleClass('form-disabled', e.value) - .find('select, input, textarea').prop('disabled', e.value); - - // Note: WebKit nightlies don't reflect that change correctly. - // See https://bugs.webkit.org/show_bug.cgi?id=23789 + $(e.target).prop('disabled', e.value).closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggleClass('form-disabled', e.value).find('select, input, textarea').prop('disabled', e.value); } }); @@ -620,13 +329,12 @@ if (e.trigger) { if (e.value) { var label = 'label' + (e.target.id ? '[for=' + e.target.id + ']' : ''); - var $label = $(e.target).attr({'required': 'required', 'aria-required': 'aria-required'}).closest('.js-form-item, .js-form-wrapper').find(label); - // Avoids duplicate required markers on initialization. + var $label = $(e.target).attr({ 'required': 'required', 'aria-required': 'aria-required' }).closest('.js-form-item, .js-form-wrapper').find(label); + if (!$label.hasClass('js-form-required').length) { $label.addClass('js-form-required form-required'); } - } - else { + } else { $(e.target).removeAttr('required aria-required').closest('.js-form-item, .js-form-wrapper').find('label.js-form-required').removeClass('js-form-required form-required'); } } @@ -652,73 +360,25 @@ } }); - /** - * These are helper functions implementing addition "operators" and don't - * implement any logic that is particular to states. - */ - - /** - * Bitwise AND with a third undefined state. - * - * @function Drupal.states~ternary - * - * @param {*} a - * Value a. - * @param {*} b - * Value b - * - * @return {bool} - * The result. - */ function ternary(a, b) { if (typeof a === 'undefined') { return b; - } - else if (typeof b === 'undefined') { + } else if (typeof b === 'undefined') { return a; - } - else { + } else { return a && b; } } - /** - * Inverts a (if it's not undefined) when invertState is true. - * - * @function Drupal.states~invert - * - * @param {*} a - * The value to maybe invert. - * @param {bool} invertState - * Whether to invert state or not. - * - * @return {bool} - * The result. - */ function invert(a, invertState) { - return (invertState && typeof a !== 'undefined') ? !a : a; + return invertState && typeof a !== 'undefined' ? !a : a; } - /** - * Compares two values while ignoring undefined values. - * - * @function Drupal.states~compare - * - * @param {*} a - * Value a. - * @param {*} b - * Value b. - * - * @return {bool} - * The comparison result. - */ - function compare(a, b) { + function _compare2(a, b) { if (a === b) { return typeof a === 'undefined' ? a : true; - } - else { + } else { return typeof a === 'undefined' || typeof b === 'undefined'; } } - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/tabbingmanager.es6.js b/core/misc/tabbingmanager.es6.js new file mode 100644 index 000000000000..134523f5bf97 --- /dev/null +++ b/core/misc/tabbingmanager.es6.js @@ -0,0 +1,369 @@ +/** + * @file + * Manages page tabbing modifications made by modules. + */ + +/** + * Allow modules to respond to the constrain event. + * + * @event drupalTabbingConstrained + */ + +/** + * Allow modules to respond to the tabbingContext release event. + * + * @event drupalTabbingContextReleased + */ + +/** + * Allow modules to respond to the constrain event. + * + * @event drupalTabbingContextActivated + */ + +/** + * Allow modules to respond to the constrain event. + * + * @event drupalTabbingContextDeactivated + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Provides an API for managing page tabbing order modifications. + * + * @constructor Drupal~TabbingManager + */ + function TabbingManager() { + + /** + * Tabbing sets are stored as a stack. The active set is at the top of the + * stack. We use a JavaScript array as if it were a stack; we consider the + * first element to be the bottom and the last element to be the top. This + * allows us to use JavaScript's built-in Array.push() and Array.pop() + * methods. + * + * @type {Array.<Drupal~TabbingContext>} + */ + this.stack = []; + } + + /** + * Add public methods to the TabbingManager class. + */ + $.extend(TabbingManager.prototype, /** @lends Drupal~TabbingManager# */{ + + /** + * Constrain tabbing to the specified set of elements only. + * + * Makes elements outside of the specified set of elements unreachable via + * the tab key. + * + * @param {jQuery} elements + * The set of elements to which tabbing should be constrained. Can also + * be a jQuery-compatible selector string. + * + * @return {Drupal~TabbingContext} + * The TabbingContext instance. + * + * @fires event:drupalTabbingConstrained + */ + constrain: function (elements) { + // Deactivate all tabbingContexts to prepare for the new constraint. A + // tabbingContext instance will only be reactivated if the stack is + // unwound to it in the _unwindStack() method. + var il = this.stack.length; + for (var i = 0; i < il; i++) { + this.stack[i].deactivate(); + } + + // The "active tabbing set" are the elements tabbing should be constrained + // to. + var $elements = $(elements).find(':tabbable').addBack(':tabbable'); + + var tabbingContext = new TabbingContext({ + // The level is the current height of the stack before this new + // tabbingContext is pushed on top of the stack. + level: this.stack.length, + $tabbableElements: $elements + }); + + this.stack.push(tabbingContext); + + // Activates the tabbingContext; this will manipulate the DOM to constrain + // tabbing. + tabbingContext.activate(); + + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingConstrained', tabbingContext); + + return tabbingContext; + }, + + /** + * Restores a former tabbingContext when an active one is released. + * + * The TabbingManager stack of tabbingContext instances will be unwound + * from the top-most released tabbingContext down to the first non-released + * tabbingContext instance. This non-released instance is then activated. + */ + release: function () { + // Unwind as far as possible: find the topmost non-released + // tabbingContext. + var toActivate = this.stack.length - 1; + while (toActivate >= 0 && this.stack[toActivate].released) { + toActivate--; + } + + // Delete all tabbingContexts after the to be activated one. They have + // already been deactivated, so their effect on the DOM has been reversed. + this.stack.splice(toActivate + 1); + + // Get topmost tabbingContext, if one exists, and activate it. + if (toActivate >= 0) { + this.stack[toActivate].activate(); + } + }, + + /** + * Makes all elements outside of the tabbingContext's set untabbable. + * + * Elements made untabbable have their original tabindex and autofocus + * values stored so that they might be restored later when this + * tabbingContext is deactivated. + * + * @param {Drupal~TabbingContext} tabbingContext + * The TabbingContext instance that has been activated. + */ + activate: function (tabbingContext) { + var $set = tabbingContext.$tabbableElements; + var level = tabbingContext.level; + // Determine which elements are reachable via tabbing by default. + var $disabledSet = $(':tabbable') + // Exclude elements of the active tabbing set. + .not($set); + // Set the disabled set on the tabbingContext. + tabbingContext.$disabledElements = $disabledSet; + // Record the tabindex for each element, so we can restore it later. + var il = $disabledSet.length; + for (var i = 0; i < il; i++) { + this.recordTabindex($disabledSet.eq(i), level); + } + // Make all tabbable elements outside of the active tabbing set + // unreachable. + $disabledSet + .prop('tabindex', -1) + .prop('autofocus', false); + + // Set focus on an element in the tabbingContext's set of tabbable + // elements. First, check if there is an element with an autofocus + // attribute. Select the last one from the DOM order. + var $hasFocus = $set.filter('[autofocus]').eq(-1); + // If no element in the tabbable set has an autofocus attribute, select + // the first element in the set. + if ($hasFocus.length === 0) { + $hasFocus = $set.eq(0); + } + $hasFocus.trigger('focus'); + }, + + /** + * Restores that tabbable state of a tabbingContext's disabled elements. + * + * Elements that were made untabbable have their original tabindex and + * autofocus values restored. + * + * @param {Drupal~TabbingContext} tabbingContext + * The TabbingContext instance that has been deactivated. + */ + deactivate: function (tabbingContext) { + var $set = tabbingContext.$disabledElements; + var level = tabbingContext.level; + var il = $set.length; + for (var i = 0; i < il; i++) { + this.restoreTabindex($set.eq(i), level); + } + }, + + /** + * Records the tabindex and autofocus values of an untabbable element. + * + * @param {jQuery} $el + * The set of elements that have been disabled. + * @param {number} level + * The stack level for which the tabindex attribute should be recorded. + */ + recordTabindex: function ($el, level) { + var tabInfo = $el.data('drupalOriginalTabIndices') || {}; + tabInfo[level] = { + tabindex: $el[0].getAttribute('tabindex'), + autofocus: $el[0].hasAttribute('autofocus') + }; + $el.data('drupalOriginalTabIndices', tabInfo); + }, + + /** + * Restores the tabindex and autofocus values of a reactivated element. + * + * @param {jQuery} $el + * The element that is being reactivated. + * @param {number} level + * The stack level for which the tabindex attribute should be restored. + */ + restoreTabindex: function ($el, level) { + var tabInfo = $el.data('drupalOriginalTabIndices'); + if (tabInfo && tabInfo[level]) { + var data = tabInfo[level]; + if (data.tabindex) { + $el[0].setAttribute('tabindex', data.tabindex); + } + // If the element did not have a tabindex at this stack level then + // remove it. + else { + $el[0].removeAttribute('tabindex'); + } + if (data.autofocus) { + $el[0].setAttribute('autofocus', 'autofocus'); + } + + // Clean up $.data. + if (level === 0) { + // Remove all data. + $el.removeData('drupalOriginalTabIndices'); + } + else { + // Remove the data for this stack level and higher. + var levelToDelete = level; + while (tabInfo.hasOwnProperty(levelToDelete)) { + delete tabInfo[levelToDelete]; + levelToDelete++; + } + $el.data('drupalOriginalTabIndices', tabInfo); + } + } + } + }); + + /** + * Stores a set of tabbable elements. + * + * This constraint can be removed with the release() method. + * + * @constructor Drupal~TabbingContext + * + * @param {object} options + * A set of initiating values + * @param {number} options.level + * The level in the TabbingManager's stack of this tabbingContext. + * @param {jQuery} options.$tabbableElements + * The DOM elements that should be reachable via the tab key when this + * tabbingContext is active. + * @param {jQuery} options.$disabledElements + * The DOM elements that should not be reachable via the tab key when this + * tabbingContext is active. + * @param {bool} options.released + * A released tabbingContext can never be activated again. It will be + * cleaned up when the TabbingManager unwinds its stack. + * @param {bool} options.active + * When true, the tabbable elements of this tabbingContext will be reachable + * via the tab key and the disabled elements will not. Only one + * tabbingContext can be active at a time. + */ + function TabbingContext(options) { + + $.extend(this, /** @lends Drupal~TabbingContext# */{ + + /** + * @type {?number} + */ + level: null, + + /** + * @type {jQuery} + */ + $tabbableElements: $(), + + /** + * @type {jQuery} + */ + $disabledElements: $(), + + /** + * @type {bool} + */ + released: false, + + /** + * @type {bool} + */ + active: false + }, options); + } + + /** + * Add public methods to the TabbingContext class. + */ + $.extend(TabbingContext.prototype, /** @lends Drupal~TabbingContext# */{ + + /** + * Releases this TabbingContext. + * + * Once a TabbingContext object is released, it can never be activated + * again. + * + * @fires event:drupalTabbingContextReleased + */ + release: function () { + if (!this.released) { + this.deactivate(); + this.released = true; + Drupal.tabbingManager.release(this); + // Allow modules to respond to the tabbingContext release event. + $(document).trigger('drupalTabbingContextReleased', this); + } + }, + + /** + * Activates this TabbingContext. + * + * @fires event:drupalTabbingContextActivated + */ + activate: function () { + // A released TabbingContext object can never be activated again. + if (!this.active && !this.released) { + this.active = true; + Drupal.tabbingManager.activate(this); + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingContextActivated', this); + } + }, + + /** + * Deactivates this TabbingContext. + * + * @fires event:drupalTabbingContextDeactivated + */ + deactivate: function () { + if (this.active) { + this.active = false; + Drupal.tabbingManager.deactivate(this); + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingContextDeactivated', this); + } + } + }); + + // Mark this behavior as processed on the first pass and return if it is + // already processed. + if (Drupal.tabbingManager) { + return; + } + + /** + * @type {Drupal~TabbingManager} + */ + Drupal.tabbingManager = new TabbingManager(); + +}(jQuery, Drupal)); diff --git a/core/misc/tabbingmanager.js b/core/misc/tabbingmanager.js index 134523f5bf97..48aa40804e0e 100644 --- a/core/misc/tabbingmanager.js +++ b/core/misc/tabbingmanager.js @@ -1,184 +1,79 @@ /** - * @file - * Manages page tabbing modifications made by modules. - */ - -/** - * Allow modules to respond to the constrain event. - * - * @event drupalTabbingConstrained - */ - -/** - * Allow modules to respond to the tabbingContext release event. - * - * @event drupalTabbingContextReleased - */ - -/** - * Allow modules to respond to the constrain event. - * - * @event drupalTabbingContextActivated - */ - -/** - * Allow modules to respond to the constrain event. - * - * @event drupalTabbingContextDeactivated - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/tabbingmanager.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Provides an API for managing page tabbing order modifications. - * - * @constructor Drupal~TabbingManager - */ function TabbingManager() { - - /** - * Tabbing sets are stored as a stack. The active set is at the top of the - * stack. We use a JavaScript array as if it were a stack; we consider the - * first element to be the bottom and the last element to be the top. This - * allows us to use JavaScript's built-in Array.push() and Array.pop() - * methods. - * - * @type {Array.<Drupal~TabbingContext>} - */ this.stack = []; } - /** - * Add public methods to the TabbingManager class. - */ - $.extend(TabbingManager.prototype, /** @lends Drupal~TabbingManager# */{ - - /** - * Constrain tabbing to the specified set of elements only. - * - * Makes elements outside of the specified set of elements unreachable via - * the tab key. - * - * @param {jQuery} elements - * The set of elements to which tabbing should be constrained. Can also - * be a jQuery-compatible selector string. - * - * @return {Drupal~TabbingContext} - * The TabbingContext instance. - * - * @fires event:drupalTabbingConstrained - */ - constrain: function (elements) { - // Deactivate all tabbingContexts to prepare for the new constraint. A - // tabbingContext instance will only be reactivated if the stack is - // unwound to it in the _unwindStack() method. + $.extend(TabbingManager.prototype, { + constrain: function constrain(elements) { var il = this.stack.length; for (var i = 0; i < il; i++) { this.stack[i].deactivate(); } - // The "active tabbing set" are the elements tabbing should be constrained - // to. var $elements = $(elements).find(':tabbable').addBack(':tabbable'); var tabbingContext = new TabbingContext({ - // The level is the current height of the stack before this new - // tabbingContext is pushed on top of the stack. level: this.stack.length, $tabbableElements: $elements }); this.stack.push(tabbingContext); - // Activates the tabbingContext; this will manipulate the DOM to constrain - // tabbing. tabbingContext.activate(); - // Allow modules to respond to the constrain event. $(document).trigger('drupalTabbingConstrained', tabbingContext); return tabbingContext; }, - /** - * Restores a former tabbingContext when an active one is released. - * - * The TabbingManager stack of tabbingContext instances will be unwound - * from the top-most released tabbingContext down to the first non-released - * tabbingContext instance. This non-released instance is then activated. - */ - release: function () { - // Unwind as far as possible: find the topmost non-released - // tabbingContext. + release: function release() { var toActivate = this.stack.length - 1; while (toActivate >= 0 && this.stack[toActivate].released) { toActivate--; } - // Delete all tabbingContexts after the to be activated one. They have - // already been deactivated, so their effect on the DOM has been reversed. this.stack.splice(toActivate + 1); - // Get topmost tabbingContext, if one exists, and activate it. if (toActivate >= 0) { this.stack[toActivate].activate(); } }, - /** - * Makes all elements outside of the tabbingContext's set untabbable. - * - * Elements made untabbable have their original tabindex and autofocus - * values stored so that they might be restored later when this - * tabbingContext is deactivated. - * - * @param {Drupal~TabbingContext} tabbingContext - * The TabbingContext instance that has been activated. - */ - activate: function (tabbingContext) { + activate: function activate(tabbingContext) { var $set = tabbingContext.$tabbableElements; var level = tabbingContext.level; - // Determine which elements are reachable via tabbing by default. - var $disabledSet = $(':tabbable') - // Exclude elements of the active tabbing set. - .not($set); - // Set the disabled set on the tabbingContext. + + var $disabledSet = $(':tabbable').not($set); + tabbingContext.$disabledElements = $disabledSet; - // Record the tabindex for each element, so we can restore it later. + var il = $disabledSet.length; for (var i = 0; i < il; i++) { this.recordTabindex($disabledSet.eq(i), level); } - // Make all tabbable elements outside of the active tabbing set - // unreachable. - $disabledSet - .prop('tabindex', -1) - .prop('autofocus', false); - - // Set focus on an element in the tabbingContext's set of tabbable - // elements. First, check if there is an element with an autofocus - // attribute. Select the last one from the DOM order. + + $disabledSet.prop('tabindex', -1).prop('autofocus', false); + var $hasFocus = $set.filter('[autofocus]').eq(-1); - // If no element in the tabbable set has an autofocus attribute, select - // the first element in the set. + if ($hasFocus.length === 0) { $hasFocus = $set.eq(0); } $hasFocus.trigger('focus'); }, - /** - * Restores that tabbable state of a tabbingContext's disabled elements. - * - * Elements that were made untabbable have their original tabindex and - * autofocus values restored. - * - * @param {Drupal~TabbingContext} tabbingContext - * The TabbingContext instance that has been deactivated. - */ - deactivate: function (tabbingContext) { + deactivate: function deactivate(tabbingContext) { var $set = tabbingContext.$disabledElements; var level = tabbingContext.level; var il = $set.length; @@ -187,15 +82,7 @@ } }, - /** - * Records the tabindex and autofocus values of an untabbable element. - * - * @param {jQuery} $el - * The set of elements that have been disabled. - * @param {number} level - * The stack level for which the tabindex attribute should be recorded. - */ - recordTabindex: function ($el, level) { + recordTabindex: function recordTabindex($el, level) { var tabInfo = $el.data('drupalOriginalTabIndices') || {}; tabInfo[level] = { tabindex: $el[0].getAttribute('tabindex'), @@ -204,37 +91,22 @@ $el.data('drupalOriginalTabIndices', tabInfo); }, - /** - * Restores the tabindex and autofocus values of a reactivated element. - * - * @param {jQuery} $el - * The element that is being reactivated. - * @param {number} level - * The stack level for which the tabindex attribute should be restored. - */ - restoreTabindex: function ($el, level) { + restoreTabindex: function restoreTabindex($el, level) { var tabInfo = $el.data('drupalOriginalTabIndices'); if (tabInfo && tabInfo[level]) { var data = tabInfo[level]; if (data.tabindex) { $el[0].setAttribute('tabindex', data.tabindex); - } - // If the element did not have a tabindex at this stack level then - // remove it. - else { - $el[0].removeAttribute('tabindex'); - } + } else { + $el[0].removeAttribute('tabindex'); + } if (data.autofocus) { $el[0].setAttribute('autofocus', 'autofocus'); } - // Clean up $.data. if (level === 0) { - // Remove all data. $el.removeData('drupalOriginalTabIndices'); - } - else { - // Remove the data for this stack level and higher. + } else { var levelToDelete = level; while (tabInfo.hasOwnProperty(levelToDelete)) { delete tabInfo[levelToDelete]; @@ -246,124 +118,54 @@ } }); - /** - * Stores a set of tabbable elements. - * - * This constraint can be removed with the release() method. - * - * @constructor Drupal~TabbingContext - * - * @param {object} options - * A set of initiating values - * @param {number} options.level - * The level in the TabbingManager's stack of this tabbingContext. - * @param {jQuery} options.$tabbableElements - * The DOM elements that should be reachable via the tab key when this - * tabbingContext is active. - * @param {jQuery} options.$disabledElements - * The DOM elements that should not be reachable via the tab key when this - * tabbingContext is active. - * @param {bool} options.released - * A released tabbingContext can never be activated again. It will be - * cleaned up when the TabbingManager unwinds its stack. - * @param {bool} options.active - * When true, the tabbable elements of this tabbingContext will be reachable - * via the tab key and the disabled elements will not. Only one - * tabbingContext can be active at a time. - */ function TabbingContext(options) { - $.extend(this, /** @lends Drupal~TabbingContext# */{ - - /** - * @type {?number} - */ + $.extend(this, { level: null, - /** - * @type {jQuery} - */ $tabbableElements: $(), - /** - * @type {jQuery} - */ $disabledElements: $(), - /** - * @type {bool} - */ released: false, - /** - * @type {bool} - */ active: false }, options); } - /** - * Add public methods to the TabbingContext class. - */ - $.extend(TabbingContext.prototype, /** @lends Drupal~TabbingContext# */{ - - /** - * Releases this TabbingContext. - * - * Once a TabbingContext object is released, it can never be activated - * again. - * - * @fires event:drupalTabbingContextReleased - */ - release: function () { + $.extend(TabbingContext.prototype, { + release: function release() { if (!this.released) { this.deactivate(); this.released = true; Drupal.tabbingManager.release(this); - // Allow modules to respond to the tabbingContext release event. + $(document).trigger('drupalTabbingContextReleased', this); } }, - /** - * Activates this TabbingContext. - * - * @fires event:drupalTabbingContextActivated - */ - activate: function () { - // A released TabbingContext object can never be activated again. + activate: function activate() { if (!this.active && !this.released) { this.active = true; Drupal.tabbingManager.activate(this); - // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingContextActivated', this); } }, - /** - * Deactivates this TabbingContext. - * - * @fires event:drupalTabbingContextDeactivated - */ - deactivate: function () { + deactivate: function deactivate() { if (this.active) { this.active = false; Drupal.tabbingManager.deactivate(this); - // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingContextDeactivated', this); } } }); - // Mark this behavior as processed on the first pass and return if it is - // already processed. if (Drupal.tabbingManager) { return; } - /** - * @type {Drupal~TabbingManager} - */ Drupal.tabbingManager = new TabbingManager(); - -}(jQuery, Drupal)); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/tabledrag.es6.js b/core/misc/tabledrag.es6.js new file mode 100644 index 000000000000..75468e60ddb4 --- /dev/null +++ b/core/misc/tabledrag.es6.js @@ -0,0 +1,1557 @@ +/** + * @file + * Provide dragging capabilities to admin uis. + */ + +/** + * Triggers when weights columns are toggled. + * + * @event columnschange + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Store the state of weight columns display for all tables. + * + * Default value is to hide weight columns. + */ + var showWeight = JSON.parse(localStorage.getItem('Drupal.tableDrag.showWeight')); + + /** + * Drag and drop table rows with field manipulation. + * + * Using the drupal_attach_tabledrag() function, any table with weights or + * parent relationships may be made into draggable tables. Columns containing + * a field may optionally be hidden, providing a better user experience. + * + * Created tableDrag instances may be modified with custom behaviors by + * overriding the .onDrag, .onDrop, .row.onSwap, and .row.onIndent methods. + * See blocks.js for an example of adding additional functionality to + * tableDrag. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.tableDrag = { + attach: function (context, settings) { + function initTableDrag(table, base) { + if (table.length) { + // Create the new tableDrag instance. Save in the Drupal variable + // to allow other scripts access to the object. + Drupal.tableDrag[base] = new Drupal.tableDrag(table[0], settings.tableDrag[base]); + } + } + + for (var base in settings.tableDrag) { + if (settings.tableDrag.hasOwnProperty(base)) { + initTableDrag($(context).find('#' + base).once('tabledrag'), base); + } + } + } + }; + + /** + * Provides table and field manipulation. + * + * @constructor + * + * @param {HTMLElement} table + * DOM object for the table to be made draggable. + * @param {object} tableSettings + * Settings for the table added via drupal_add_dragtable(). + */ + Drupal.tableDrag = function (table, tableSettings) { + var self = this; + var $table = $(table); + + /** + * @type {jQuery} + */ + this.$table = $(table); + + /** + * + * @type {HTMLElement} + */ + this.table = table; + + /** + * @type {object} + */ + this.tableSettings = tableSettings; + + /** + * Used to hold information about a current drag operation. + * + * @type {?HTMLElement} + */ + this.dragObject = null; + + /** + * Provides operations for row manipulation. + * + * @type {?HTMLElement} + */ + this.rowObject = null; + + /** + * Remember the previous element. + * + * @type {?HTMLElement} + */ + this.oldRowElement = null; + + /** + * Used to determine up or down direction from last mouse move. + * + * @type {number} + */ + this.oldY = 0; + + /** + * Whether anything in the entire table has changed. + * + * @type {bool} + */ + this.changed = false; + + /** + * Maximum amount of allowed parenting. + * + * @type {number} + */ + this.maxDepth = 0; + + /** + * Direction of the table. + * + * @type {number} + */ + this.rtl = $(this.table).css('direction') === 'rtl' ? -1 : 1; + + /** + * + * @type {bool} + */ + this.striping = $(this.table).data('striping') === 1; + + /** + * Configure the scroll settings. + * + * @type {object} + * + * @prop {number} amount + * @prop {number} interval + * @prop {number} trigger + */ + this.scrollSettings = {amount: 4, interval: 50, trigger: 70}; + + /** + * + * @type {?number} + */ + this.scrollInterval = null; + + /** + * + * @type {number} + */ + this.scrollY = 0; + + /** + * + * @type {number} + */ + this.windowHeight = 0; + + /** + * Check this table's settings for parent relationships. + * + * For efficiency, large sections of code can be skipped if we don't need to + * track horizontal movement and indentations. + * + * @type {bool} + */ + this.indentEnabled = false; + for (var group in tableSettings) { + if (tableSettings.hasOwnProperty(group)) { + for (var n in tableSettings[group]) { + if (tableSettings[group].hasOwnProperty(n)) { + if (tableSettings[group][n].relationship === 'parent') { + this.indentEnabled = true; + } + if (tableSettings[group][n].limit > 0) { + this.maxDepth = tableSettings[group][n].limit; + } + } + } + } + } + if (this.indentEnabled) { + + /** + * Total width of indents, set in makeDraggable. + * + * @type {number} + */ + this.indentCount = 1; + // Find the width of indentations to measure mouse movements against. + // Because the table doesn't need to start with any indentations, we + // manually append 2 indentations in the first draggable row, measure + // the offset, then remove. + var indent = Drupal.theme('tableDragIndentation'); + var testRow = $('<tr/>').addClass('draggable').appendTo(table); + var testCell = $('<td/>').appendTo(testRow).prepend(indent).prepend(indent); + var $indentation = testCell.find('.js-indentation'); + + /** + * + * @type {number} + */ + this.indentAmount = $indentation.get(1).offsetLeft - $indentation.get(0).offsetLeft; + testRow.remove(); + } + + // Make each applicable row draggable. + // Match immediate children of the parent element to allow nesting. + $table.find('> tr.draggable, > tbody > tr.draggable').each(function () { self.makeDraggable(this); }); + + // Add a link before the table for users to show or hide weight columns. + $table.before($('<button type="button" class="link tabledrag-toggle-weight"></button>') + .attr('title', Drupal.t('Re-order rows by numerical weight instead of dragging.')) + .on('click', $.proxy(function (e) { + e.preventDefault(); + this.toggleColumns(); + }, this)) + .wrap('<div class="tabledrag-toggle-weight-wrapper"></div>') + .parent() + ); + + // Initialize the specified columns (for example, weight or parent columns) + // to show or hide according to user preference. This aids accessibility + // so that, e.g., screen reader users can choose to enter weight values and + // manipulate form elements directly, rather than using drag-and-drop.. + self.initColumns(); + + // Add event bindings to the document. The self variable is passed along + // as event handlers do not have direct access to the tableDrag object. + $(document).on('touchmove', function (event) { return self.dragRow(event.originalEvent.touches[0], self); }); + $(document).on('touchend', function (event) { return self.dropRow(event.originalEvent.touches[0], self); }); + $(document).on('mousemove pointermove', function (event) { return self.dragRow(event, self); }); + $(document).on('mouseup pointerup', function (event) { return self.dropRow(event, self); }); + + // React to localStorage event showing or hiding weight columns. + $(window).on('storage', $.proxy(function (e) { + // Only react to 'Drupal.tableDrag.showWeight' value change. + if (e.originalEvent.key === 'Drupal.tableDrag.showWeight') { + // This was changed in another window, get the new value for this + // window. + showWeight = JSON.parse(e.originalEvent.newValue); + this.displayColumns(showWeight); + } + }, this)); + }; + + /** + * Initialize columns containing form elements to be hidden by default. + * + * Identify and mark each cell with a CSS class so we can easily toggle + * show/hide it. Finally, hide columns if user does not have a + * 'Drupal.tableDrag.showWeight' localStorage value. + */ + Drupal.tableDrag.prototype.initColumns = function () { + var $table = this.$table; + var hidden; + var cell; + var columnIndex; + for (var group in this.tableSettings) { + if (this.tableSettings.hasOwnProperty(group)) { + + // Find the first field in this group. + for (var d in this.tableSettings[group]) { + if (this.tableSettings[group].hasOwnProperty(d)) { + var field = $table.find('.' + this.tableSettings[group][d].target).eq(0); + if (field.length && this.tableSettings[group][d].hidden) { + hidden = this.tableSettings[group][d].hidden; + cell = field.closest('td'); + break; + } + } + } + + // Mark the column containing this field so it can be hidden. + if (hidden && cell[0]) { + // Add 1 to our indexes. The nth-child selector is 1 based, not 0 + // based. Match immediate children of the parent element to allow + // nesting. + columnIndex = cell.parent().find('> td').index(cell.get(0)) + 1; + $table.find('> thead > tr, > tbody > tr, > tr').each(this.addColspanClass(columnIndex)); + } + } + } + this.displayColumns(showWeight); + }; + + /** + * Mark cells that have colspan. + * + * In order to adjust the colspan instead of hiding them altogether. + * + * @param {number} columnIndex + * The column index to add colspan class to. + * + * @return {function} + * Function to add colspan class. + */ + Drupal.tableDrag.prototype.addColspanClass = function (columnIndex) { + return function () { + // Get the columnIndex and adjust for any colspans in this row. + var $row = $(this); + var index = columnIndex; + var cells = $row.children(); + var cell; + cells.each(function (n) { + if (n < index && this.colSpan && this.colSpan > 1) { + index -= this.colSpan - 1; + } + }); + if (index > 0) { + cell = cells.filter(':nth-child(' + index + ')'); + if (cell[0].colSpan && cell[0].colSpan > 1) { + // If this cell has a colspan, mark it so we can reduce the colspan. + cell.addClass('tabledrag-has-colspan'); + } + else { + // Mark this cell so we can hide it. + cell.addClass('tabledrag-hide'); + } + } + }; + }; + + /** + * Hide or display weight columns. Triggers an event on change. + * + * @fires event:columnschange + * + * @param {bool} displayWeight + * 'true' will show weight columns. + */ + Drupal.tableDrag.prototype.displayColumns = function (displayWeight) { + if (displayWeight) { + this.showColumns(); + } + // Default action is to hide columns. + else { + this.hideColumns(); + } + // Trigger an event to allow other scripts to react to this display change. + // Force the extra parameter as a bool. + $('table').findOnce('tabledrag').trigger('columnschange', !!displayWeight); + }; + + /** + * Toggle the weight column depending on 'showWeight' value. + * + * Store only default override. + */ + Drupal.tableDrag.prototype.toggleColumns = function () { + showWeight = !showWeight; + this.displayColumns(showWeight); + if (showWeight) { + // Save default override. + localStorage.setItem('Drupal.tableDrag.showWeight', showWeight); + } + else { + // Reset the value to its default. + localStorage.removeItem('Drupal.tableDrag.showWeight'); + } + }; + + /** + * Hide the columns containing weight/parent form elements. + * + * Undo showColumns(). + */ + Drupal.tableDrag.prototype.hideColumns = function () { + var $tables = $('table').findOnce('tabledrag'); + // Hide weight/parent cells and headers. + $tables.find('.tabledrag-hide').css('display', 'none'); + // Show TableDrag handles. + $tables.find('.tabledrag-handle').css('display', ''); + // Reduce the colspan of any effected multi-span columns. + $tables.find('.tabledrag-has-colspan').each(function () { + this.colSpan = this.colSpan - 1; + }); + // Change link text. + $('.tabledrag-toggle-weight').text(Drupal.t('Show row weights')); + }; + + /** + * Show the columns containing weight/parent form elements. + * + * Undo hideColumns(). + */ + Drupal.tableDrag.prototype.showColumns = function () { + var $tables = $('table').findOnce('tabledrag'); + // Show weight/parent cells and headers. + $tables.find('.tabledrag-hide').css('display', ''); + // Hide TableDrag handles. + $tables.find('.tabledrag-handle').css('display', 'none'); + // Increase the colspan for any columns where it was previously reduced. + $tables.find('.tabledrag-has-colspan').each(function () { + this.colSpan = this.colSpan + 1; + }); + // Change link text. + $('.tabledrag-toggle-weight').text(Drupal.t('Hide row weights')); + }; + + /** + * Find the target used within a particular row and group. + * + * @param {string} group + * Group selector. + * @param {HTMLElement} row + * The row HTML element. + * + * @return {object} + * The table row settings. + */ + Drupal.tableDrag.prototype.rowSettings = function (group, row) { + var field = $(row).find('.' + group); + var tableSettingsGroup = this.tableSettings[group]; + for (var delta in tableSettingsGroup) { + if (tableSettingsGroup.hasOwnProperty(delta)) { + var targetClass = tableSettingsGroup[delta].target; + if (field.is('.' + targetClass)) { + // Return a copy of the row settings. + var rowSettings = {}; + for (var n in tableSettingsGroup[delta]) { + if (tableSettingsGroup[delta].hasOwnProperty(n)) { + rowSettings[n] = tableSettingsGroup[delta][n]; + } + } + return rowSettings; + } + } + } + }; + + /** + * Take an item and add event handlers to make it become draggable. + * + * @param {HTMLElement} item + * The item to add event handlers to. + */ + Drupal.tableDrag.prototype.makeDraggable = function (item) { + var self = this; + var $item = $(item); + // Add a class to the title link. + $item.find('td:first-of-type').find('a').addClass('menu-item__link'); + // Create the handle. + var handle = $('<a href="#" class="tabledrag-handle"><div class="handle"> </div></a>').attr('title', Drupal.t('Drag to re-order')); + // Insert the handle after indentations (if any). + var $indentationLast = $item.find('td:first-of-type').find('.js-indentation').eq(-1); + if ($indentationLast.length) { + $indentationLast.after(handle); + // Update the total width of indentation in this entire table. + self.indentCount = Math.max($item.find('.js-indentation').length, self.indentCount); + } + else { + $item.find('td').eq(0).prepend(handle); + } + + handle.on('mousedown touchstart pointerdown', function (event) { + event.preventDefault(); + if (event.originalEvent.type === 'touchstart') { + event = event.originalEvent.touches[0]; + } + self.dragStart(event, self, item); + }); + + // Prevent the anchor tag from jumping us to the top of the page. + handle.on('click', function (e) { + e.preventDefault(); + }); + + // Set blur cleanup when a handle is focused. + handle.on('focus', function () { + self.safeBlur = true; + }); + + // On blur, fire the same function as a touchend/mouseup. This is used to + // update values after a row has been moved through the keyboard support. + handle.on('blur', function (event) { + if (self.rowObject && self.safeBlur) { + self.dropRow(event, self); + } + }); + + // Add arrow-key support to the handle. + handle.on('keydown', function (event) { + // If a rowObject doesn't yet exist and this isn't the tab key. + if (event.keyCode !== 9 && !self.rowObject) { + self.rowObject = new self.row(item, 'keyboard', self.indentEnabled, self.maxDepth, true); + } + + var keyChange = false; + var groupHeight; + + /* eslint-disable no-fallthrough */ + + switch (event.keyCode) { + // Left arrow. + case 37: + // Safari left arrow. + case 63234: + keyChange = true; + self.rowObject.indent(-1 * self.rtl); + break; + + // Up arrow. + case 38: + // Safari up arrow. + case 63232: + var $previousRow = $(self.rowObject.element).prev('tr:first-of-type'); + var previousRow = $previousRow.get(0); + while (previousRow && $previousRow.is(':hidden')) { + $previousRow = $(previousRow).prev('tr:first-of-type'); + previousRow = $previousRow.get(0); + } + if (previousRow) { + // Do not allow the onBlur cleanup. + self.safeBlur = false; + self.rowObject.direction = 'up'; + keyChange = true; + + if ($(item).is('.tabledrag-root')) { + // Swap with the previous top-level row. + groupHeight = 0; + while (previousRow && $previousRow.find('.js-indentation').length) { + $previousRow = $(previousRow).prev('tr:first-of-type'); + previousRow = $previousRow.get(0); + groupHeight += $previousRow.is(':hidden') ? 0 : previousRow.offsetHeight; + } + if (previousRow) { + self.rowObject.swap('before', previousRow); + // No need to check for indentation, 0 is the only valid one. + window.scrollBy(0, -groupHeight); + } + } + else if (self.table.tBodies[0].rows[0] !== previousRow || $previousRow.is('.draggable')) { + // Swap with the previous row (unless previous row is the first + // one and undraggable). + self.rowObject.swap('before', previousRow); + self.rowObject.interval = null; + self.rowObject.indent(0); + window.scrollBy(0, -parseInt(item.offsetHeight, 10)); + } + // Regain focus after the DOM manipulation. + handle.trigger('focus'); + } + break; + + // Right arrow. + case 39: + // Safari right arrow. + case 63235: + keyChange = true; + self.rowObject.indent(self.rtl); + break; + + // Down arrow. + case 40: + // Safari down arrow. + case 63233: + var $nextRow = $(self.rowObject.group).eq(-1).next('tr:first-of-type'); + var nextRow = $nextRow.get(0); + while (nextRow && $nextRow.is(':hidden')) { + $nextRow = $(nextRow).next('tr:first-of-type'); + nextRow = $nextRow.get(0); + } + if (nextRow) { + // Do not allow the onBlur cleanup. + self.safeBlur = false; + self.rowObject.direction = 'down'; + keyChange = true; + + if ($(item).is('.tabledrag-root')) { + // Swap with the next group (necessarily a top-level one). + groupHeight = 0; + var nextGroup = new self.row(nextRow, 'keyboard', self.indentEnabled, self.maxDepth, false); + if (nextGroup) { + $(nextGroup.group).each(function () { + groupHeight += $(this).is(':hidden') ? 0 : this.offsetHeight; + }); + var nextGroupRow = $(nextGroup.group).eq(-1).get(0); + self.rowObject.swap('after', nextGroupRow); + // No need to check for indentation, 0 is the only valid one. + window.scrollBy(0, parseInt(groupHeight, 10)); + } + } + else { + // Swap with the next row. + self.rowObject.swap('after', nextRow); + self.rowObject.interval = null; + self.rowObject.indent(0); + window.scrollBy(0, parseInt(item.offsetHeight, 10)); + } + // Regain focus after the DOM manipulation. + handle.trigger('focus'); + } + break; + } + + /* eslint-enable no-fallthrough */ + + if (self.rowObject && self.rowObject.changed === true) { + $(item).addClass('drag'); + if (self.oldRowElement) { + $(self.oldRowElement).removeClass('drag-previous'); + } + self.oldRowElement = item; + if (self.striping === true) { + self.restripeTable(); + } + self.onDrag(); + } + + // Returning false if we have an arrow key to prevent scrolling. + if (keyChange) { + return false; + } + }); + + // Compatibility addition, return false on keypress to prevent unwanted + // scrolling. IE and Safari will suppress scrolling on keydown, but all + // other browsers need to return false on keypress. + // http://www.quirksmode.org/js/keys.html + handle.on('keypress', function (event) { + + /* eslint-disable no-fallthrough */ + + switch (event.keyCode) { + // Left arrow. + case 37: + // Up arrow. + case 38: + // Right arrow. + case 39: + // Down arrow. + case 40: + return false; + } + + /* eslint-enable no-fallthrough */ + + }); + }; + + /** + * Pointer event initiator, creates drag object and information. + * + * @param {jQuery.Event} event + * The event object that trigger the drag. + * @param {Drupal.tableDrag} self + * The drag handle. + * @param {HTMLElement} item + * The item that that is being dragged. + */ + Drupal.tableDrag.prototype.dragStart = function (event, self, item) { + // Create a new dragObject recording the pointer information. + self.dragObject = {}; + self.dragObject.initOffset = self.getPointerOffset(item, event); + self.dragObject.initPointerCoords = self.pointerCoords(event); + if (self.indentEnabled) { + self.dragObject.indentPointerPos = self.dragObject.initPointerCoords; + } + + // If there's a lingering row object from the keyboard, remove its focus. + if (self.rowObject) { + $(self.rowObject.element).find('a.tabledrag-handle').trigger('blur'); + } + + // Create a new rowObject for manipulation of this row. + self.rowObject = new self.row(item, 'pointer', self.indentEnabled, self.maxDepth, true); + + // Save the position of the table. + self.table.topY = $(self.table).offset().top; + self.table.bottomY = self.table.topY + self.table.offsetHeight; + + // Add classes to the handle and row. + $(item).addClass('drag'); + + // Set the document to use the move cursor during drag. + $('body').addClass('drag'); + if (self.oldRowElement) { + $(self.oldRowElement).removeClass('drag-previous'); + } + }; + + /** + * Pointer movement handler, bound to document. + * + * @param {jQuery.Event} event + * The pointer event. + * @param {Drupal.tableDrag} self + * The tableDrag instance. + * + * @return {bool|undefined} + * Undefined if no dragObject is defined, false otherwise. + */ + Drupal.tableDrag.prototype.dragRow = function (event, self) { + if (self.dragObject) { + self.currentPointerCoords = self.pointerCoords(event); + var y = self.currentPointerCoords.y - self.dragObject.initOffset.y; + var x = self.currentPointerCoords.x - self.dragObject.initOffset.x; + + // Check for row swapping and vertical scrolling. + if (y !== self.oldY) { + self.rowObject.direction = y > self.oldY ? 'down' : 'up'; + // Update the old value. + self.oldY = y; + // Check if the window should be scrolled (and how fast). + var scrollAmount = self.checkScroll(self.currentPointerCoords.y); + // Stop any current scrolling. + clearInterval(self.scrollInterval); + // Continue scrolling if the mouse has moved in the scroll direction. + if (scrollAmount > 0 && self.rowObject.direction === 'down' || scrollAmount < 0 && self.rowObject.direction === 'up') { + self.setScroll(scrollAmount); + } + + // If we have a valid target, perform the swap and restripe the table. + var currentRow = self.findDropTargetRow(x, y); + if (currentRow) { + if (self.rowObject.direction === 'down') { + self.rowObject.swap('after', currentRow, self); + } + else { + self.rowObject.swap('before', currentRow, self); + } + if (self.striping === true) { + self.restripeTable(); + } + } + } + + // Similar to row swapping, handle indentations. + if (self.indentEnabled) { + var xDiff = self.currentPointerCoords.x - self.dragObject.indentPointerPos.x; + // Set the number of indentations the pointer has been moved left or + // right. + var indentDiff = Math.round(xDiff / self.indentAmount); + // Indent the row with our estimated diff, which may be further + // restricted according to the rows around this row. + var indentChange = self.rowObject.indent(indentDiff); + // Update table and pointer indentations. + self.dragObject.indentPointerPos.x += self.indentAmount * indentChange * self.rtl; + self.indentCount = Math.max(self.indentCount, self.rowObject.indents); + } + + return false; + } + }; + + /** + * Pointerup behavior. + * + * @param {jQuery.Event} event + * The pointer event. + * @param {Drupal.tableDrag} self + * The tableDrag instance. + */ + Drupal.tableDrag.prototype.dropRow = function (event, self) { + var droppedRow; + var $droppedRow; + + // Drop row functionality. + if (self.rowObject !== null) { + droppedRow = self.rowObject.element; + $droppedRow = $(droppedRow); + // The row is already in the right place so we just release it. + if (self.rowObject.changed === true) { + // Update the fields in the dropped row. + self.updateFields(droppedRow); + + // If a setting exists for affecting the entire group, update all the + // fields in the entire dragged group. + for (var group in self.tableSettings) { + if (self.tableSettings.hasOwnProperty(group)) { + var rowSettings = self.rowSettings(group, droppedRow); + if (rowSettings.relationship === 'group') { + for (var n in self.rowObject.children) { + if (self.rowObject.children.hasOwnProperty(n)) { + self.updateField(self.rowObject.children[n], group); + } + } + } + } + } + + self.rowObject.markChanged(); + if (self.changed === false) { + $(Drupal.theme('tableDragChangedWarning')).insertBefore(self.table).hide().fadeIn('slow'); + self.changed = true; + } + } + + if (self.indentEnabled) { + self.rowObject.removeIndentClasses(); + } + if (self.oldRowElement) { + $(self.oldRowElement).removeClass('drag-previous'); + } + $droppedRow.removeClass('drag').addClass('drag-previous'); + self.oldRowElement = droppedRow; + self.onDrop(); + self.rowObject = null; + } + + // Functionality specific only to pointerup events. + if (self.dragObject !== null) { + self.dragObject = null; + $('body').removeClass('drag'); + clearInterval(self.scrollInterval); + } + }; + + /** + * Get the coordinates from the event (allowing for browser differences). + * + * @param {jQuery.Event} event + * The pointer event. + * + * @return {object} + * An object with `x` and `y` keys indicating the position. + */ + Drupal.tableDrag.prototype.pointerCoords = function (event) { + if (event.pageX || event.pageY) { + return {x: event.pageX, y: event.pageY}; + } + return { + x: event.clientX + document.body.scrollLeft - document.body.clientLeft, + y: event.clientY + document.body.scrollTop - document.body.clientTop + }; + }; + + /** + * Get the event offset from the target element. + * + * Given a target element and a pointer event, get the event offset from that + * element. To do this we need the element's position and the target position. + * + * @param {HTMLElement} target + * The target HTML element. + * @param {jQuery.Event} event + * The pointer event. + * + * @return {object} + * An object with `x` and `y` keys indicating the position. + */ + Drupal.tableDrag.prototype.getPointerOffset = function (target, event) { + var docPos = $(target).offset(); + var pointerPos = this.pointerCoords(event); + return {x: pointerPos.x - docPos.left, y: pointerPos.y - docPos.top}; + }; + + /** + * Find the row the mouse is currently over. + * + * This row is then taken and swapped with the one being dragged. + * + * @param {number} x + * The x coordinate of the mouse on the page (not the screen). + * @param {number} y + * The y coordinate of the mouse on the page (not the screen). + * + * @return {*} + * The drop target row, if found. + */ + Drupal.tableDrag.prototype.findDropTargetRow = function (x, y) { + var rows = $(this.table.tBodies[0].rows).not(':hidden'); + for (var n = 0; n < rows.length; n++) { + var row = rows[n]; + var $row = $(row); + var rowY = $row.offset().top; + var rowHeight; + // Because Safari does not report offsetHeight on table rows, but does on + // table cells, grab the firstChild of the row and use that instead. + // http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari. + if (row.offsetHeight === 0) { + rowHeight = parseInt(row.firstChild.offsetHeight, 10) / 2; + } + // Other browsers. + else { + rowHeight = parseInt(row.offsetHeight, 10) / 2; + } + + // Because we always insert before, we need to offset the height a bit. + if ((y > (rowY - rowHeight)) && (y < (rowY + rowHeight))) { + if (this.indentEnabled) { + // Check that this row is not a child of the row being dragged. + for (n in this.rowObject.group) { + if (this.rowObject.group[n] === row) { + return null; + } + } + } + else { + // Do not allow a row to be swapped with itself. + if (row === this.rowObject.element) { + return null; + } + } + + // Check that swapping with this row is allowed. + if (!this.rowObject.isValidSwap(row)) { + return null; + } + + // We may have found the row the mouse just passed over, but it doesn't + // take into account hidden rows. Skip backwards until we find a + // draggable row. + while ($row.is(':hidden') && $row.prev('tr').is(':hidden')) { + $row = $row.prev('tr:first-of-type'); + row = $row.get(0); + } + return row; + } + } + return null; + }; + + /** + * After the row is dropped, update the table fields. + * + * @param {HTMLElement} changedRow + * DOM object for the row that was just dropped. + */ + Drupal.tableDrag.prototype.updateFields = function (changedRow) { + for (var group in this.tableSettings) { + if (this.tableSettings.hasOwnProperty(group)) { + // Each group may have a different setting for relationship, so we find + // the source rows for each separately. + this.updateField(changedRow, group); + } + } + }; + + /** + * After the row is dropped, update a single table field. + * + * @param {HTMLElement} changedRow + * DOM object for the row that was just dropped. + * @param {string} group + * The settings group on which field updates will occur. + */ + Drupal.tableDrag.prototype.updateField = function (changedRow, group) { + var rowSettings = this.rowSettings(group, changedRow); + var $changedRow = $(changedRow); + var sourceRow; + var $previousRow; + var previousRow; + var useSibling; + // Set the row as its own target. + if (rowSettings.relationship === 'self' || rowSettings.relationship === 'group') { + sourceRow = changedRow; + } + // Siblings are easy, check previous and next rows. + else if (rowSettings.relationship === 'sibling') { + $previousRow = $changedRow.prev('tr:first-of-type'); + previousRow = $previousRow.get(0); + var $nextRow = $changedRow.next('tr:first-of-type'); + var nextRow = $nextRow.get(0); + sourceRow = changedRow; + if ($previousRow.is('.draggable') && $previousRow.find('.' + group).length) { + if (this.indentEnabled) { + if ($previousRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) { + sourceRow = previousRow; + } + } + else { + sourceRow = previousRow; + } + } + else if ($nextRow.is('.draggable') && $nextRow.find('.' + group).length) { + if (this.indentEnabled) { + if ($nextRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) { + sourceRow = nextRow; + } + } + else { + sourceRow = nextRow; + } + } + } + // Parents, look up the tree until we find a field not in this group. + // Go up as many parents as indentations in the changed row. + else if (rowSettings.relationship === 'parent') { + $previousRow = $changedRow.prev('tr'); + previousRow = $previousRow; + while ($previousRow.length && $previousRow.find('.js-indentation').length >= this.rowObject.indents) { + $previousRow = $previousRow.prev('tr'); + previousRow = $previousRow; + } + // If we found a row. + if ($previousRow.length) { + sourceRow = $previousRow.get(0); + } + // Otherwise we went all the way to the left of the table without finding + // a parent, meaning this item has been placed at the root level. + else { + // Use the first row in the table as source, because it's guaranteed to + // be at the root level. Find the first item, then compare this row + // against it as a sibling. + sourceRow = $(this.table).find('tr.draggable:first-of-type').get(0); + if (sourceRow === this.rowObject.element) { + sourceRow = $(this.rowObject.group[this.rowObject.group.length - 1]).next('tr.draggable').get(0); + } + useSibling = true; + } + } + + // Because we may have moved the row from one category to another, + // take a look at our sibling and borrow its sources and targets. + this.copyDragClasses(sourceRow, changedRow, group); + rowSettings = this.rowSettings(group, changedRow); + + // In the case that we're looking for a parent, but the row is at the top + // of the tree, copy our sibling's values. + if (useSibling) { + rowSettings.relationship = 'sibling'; + rowSettings.source = rowSettings.target; + } + + var targetClass = '.' + rowSettings.target; + var targetElement = $changedRow.find(targetClass).get(0); + + // Check if a target element exists in this row. + if (targetElement) { + var sourceClass = '.' + rowSettings.source; + var sourceElement = $(sourceClass, sourceRow).get(0); + switch (rowSettings.action) { + case 'depth': + // Get the depth of the target row. + targetElement.value = $(sourceElement).closest('tr').find('.js-indentation').length; + break; + + case 'match': + // Update the value. + targetElement.value = sourceElement.value; + break; + + case 'order': + var siblings = this.rowObject.findSiblings(rowSettings); + if ($(targetElement).is('select')) { + // Get a list of acceptable values. + var values = []; + $(targetElement).find('option').each(function () { + values.push(this.value); + }); + var maxVal = values[values.length - 1]; + // Populate the values in the siblings. + $(siblings).find(targetClass).each(function () { + // If there are more items than possible values, assign the + // maximum value to the row. + if (values.length > 0) { + this.value = values.shift(); + } + else { + this.value = maxVal; + } + }); + } + else { + // Assume a numeric input field. + var weight = parseInt($(siblings[0]).find(targetClass).val(), 10) || 0; + $(siblings).find(targetClass).each(function () { + this.value = weight; + weight++; + }); + } + break; + } + } + }; + + /** + * Copy all tableDrag related classes from one row to another. + * + * Copy all special tableDrag classes from one row's form elements to a + * different one, removing any special classes that the destination row + * may have had. + * + * @param {HTMLElement} sourceRow + * The element for the source row. + * @param {HTMLElement} targetRow + * The element for the target row. + * @param {string} group + * The group selector. + */ + Drupal.tableDrag.prototype.copyDragClasses = function (sourceRow, targetRow, group) { + var sourceElement = $(sourceRow).find('.' + group); + var targetElement = $(targetRow).find('.' + group); + if (sourceElement.length && targetElement.length) { + targetElement[0].className = sourceElement[0].className; + } + }; + + /** + * Check the suggested scroll of the table. + * + * @param {number} cursorY + * The Y position of the cursor. + * + * @return {number} + * The suggested scroll. + */ + Drupal.tableDrag.prototype.checkScroll = function (cursorY) { + var de = document.documentElement; + var b = document.body; + + var windowHeight = this.windowHeight = window.innerHeight || (de.clientHeight && de.clientWidth !== 0 ? de.clientHeight : b.offsetHeight); + var scrollY; + if (document.all) { + scrollY = this.scrollY = !de.scrollTop ? b.scrollTop : de.scrollTop; + } + else { + scrollY = this.scrollY = window.pageYOffset ? window.pageYOffset : window.scrollY; + } + var trigger = this.scrollSettings.trigger; + var delta = 0; + + // Return a scroll speed relative to the edge of the screen. + if (cursorY - scrollY > windowHeight - trigger) { + delta = trigger / (windowHeight + scrollY - cursorY); + delta = (delta > 0 && delta < trigger) ? delta : trigger; + return delta * this.scrollSettings.amount; + } + else if (cursorY - scrollY < trigger) { + delta = trigger / (cursorY - scrollY); + delta = (delta > 0 && delta < trigger) ? delta : trigger; + return -delta * this.scrollSettings.amount; + } + }; + + /** + * Set the scroll for the table. + * + * @param {number} scrollAmount + * The amount of scroll to apply to the window. + */ + Drupal.tableDrag.prototype.setScroll = function (scrollAmount) { + var self = this; + + this.scrollInterval = setInterval(function () { + // Update the scroll values stored in the object. + self.checkScroll(self.currentPointerCoords.y); + var aboveTable = self.scrollY > self.table.topY; + var belowTable = self.scrollY + self.windowHeight < self.table.bottomY; + if (scrollAmount > 0 && belowTable || scrollAmount < 0 && aboveTable) { + window.scrollBy(0, scrollAmount); + } + }, this.scrollSettings.interval); + }; + + /** + * Command to restripe table properly. + */ + Drupal.tableDrag.prototype.restripeTable = function () { + // :even and :odd are reversed because jQuery counts from 0 and + // we count from 1, so we're out of sync. + // Match immediate children of the parent element to allow nesting. + $(this.table).find('> tbody > tr.draggable, > tr.draggable') + .filter(':visible') + .filter(':odd').removeClass('odd').addClass('even').end() + .filter(':even').removeClass('even').addClass('odd'); + }; + + /** + * Stub function. Allows a custom handler when a row begins dragging. + * + * @return {null} + * Returns null when the stub function is used. + */ + Drupal.tableDrag.prototype.onDrag = function () { + return null; + }; + + /** + * Stub function. Allows a custom handler when a row is dropped. + * + * @return {null} + * Returns null when the stub function is used. + */ + Drupal.tableDrag.prototype.onDrop = function () { + return null; + }; + + /** + * Constructor to make a new object to manipulate a table row. + * + * @param {HTMLElement} tableRow + * The DOM element for the table row we will be manipulating. + * @param {string} method + * The method in which this row is being moved. Either 'keyboard' or + * 'mouse'. + * @param {bool} indentEnabled + * Whether the containing table uses indentations. Used for optimizations. + * @param {number} maxDepth + * The maximum amount of indentations this row may contain. + * @param {bool} addClasses + * Whether we want to add classes to this row to indicate child + * relationships. + */ + Drupal.tableDrag.prototype.row = function (tableRow, method, indentEnabled, maxDepth, addClasses) { + var $tableRow = $(tableRow); + + this.element = tableRow; + this.method = method; + this.group = [tableRow]; + this.groupDepth = $tableRow.find('.js-indentation').length; + this.changed = false; + this.table = $tableRow.closest('table')[0]; + this.indentEnabled = indentEnabled; + this.maxDepth = maxDepth; + // Direction the row is being moved. + this.direction = ''; + if (this.indentEnabled) { + this.indents = $tableRow.find('.js-indentation').length; + this.children = this.findChildren(addClasses); + this.group = $.merge(this.group, this.children); + // Find the depth of this entire group. + for (var n = 0; n < this.group.length; n++) { + this.groupDepth = Math.max($(this.group[n]).find('.js-indentation').length, this.groupDepth); + } + } + }; + + /** + * Find all children of rowObject by indentation. + * + * @param {bool} addClasses + * Whether we want to add classes to this row to indicate child + * relationships. + * + * @return {Array} + * An array of children of the row. + */ + Drupal.tableDrag.prototype.row.prototype.findChildren = function (addClasses) { + var parentIndentation = this.indents; + var currentRow = $(this.element, this.table).next('tr.draggable'); + var rows = []; + var child = 0; + + function rowIndentation(indentNum, el) { + var self = $(el); + if (child === 1 && (indentNum === parentIndentation)) { + self.addClass('tree-child-first'); + } + if (indentNum === parentIndentation) { + self.addClass('tree-child'); + } + else if (indentNum > parentIndentation) { + self.addClass('tree-child-horizontal'); + } + } + + while (currentRow.length) { + // A greater indentation indicates this is a child. + if (currentRow.find('.js-indentation').length > parentIndentation) { + child++; + rows.push(currentRow[0]); + if (addClasses) { + currentRow.find('.js-indentation').each(rowIndentation); + } + } + else { + break; + } + currentRow = currentRow.next('tr.draggable'); + } + if (addClasses && rows.length) { + $(rows[rows.length - 1]).find('.js-indentation:nth-child(' + (parentIndentation + 1) + ')').addClass('tree-child-last'); + } + return rows; + }; + + /** + * Ensure that two rows are allowed to be swapped. + * + * @param {HTMLElement} row + * DOM object for the row being considered for swapping. + * + * @return {bool} + * Whether the swap is a valid swap or not. + */ + Drupal.tableDrag.prototype.row.prototype.isValidSwap = function (row) { + var $row = $(row); + if (this.indentEnabled) { + var prevRow; + var nextRow; + if (this.direction === 'down') { + prevRow = row; + nextRow = $row.next('tr').get(0); + } + else { + prevRow = $row.prev('tr').get(0); + nextRow = row; + } + this.interval = this.validIndentInterval(prevRow, nextRow); + + // We have an invalid swap if the valid indentations interval is empty. + if (this.interval.min > this.interval.max) { + return false; + } + } + + // Do not let an un-draggable first row have anything put before it. + if (this.table.tBodies[0].rows[0] === row && $row.is(':not(.draggable)')) { + return false; + } + + return true; + }; + + /** + * Perform the swap between two rows. + * + * @param {string} position + * Whether the swap will occur 'before' or 'after' the given row. + * @param {HTMLElement} row + * DOM element what will be swapped with the row group. + */ + Drupal.tableDrag.prototype.row.prototype.swap = function (position, row) { + // Makes sure only DOM object are passed to Drupal.detachBehaviors(). + this.group.forEach(function (row) { + Drupal.detachBehaviors(row, drupalSettings, 'move'); + }); + $(row)[position](this.group); + // Makes sure only DOM object are passed to Drupal.attachBehaviors()s. + this.group.forEach(function (row) { + Drupal.attachBehaviors(row, drupalSettings); + }); + this.changed = true; + this.onSwap(row); + }; + + /** + * Determine the valid indentations interval for the row at a given position. + * + * @param {?HTMLElement} prevRow + * DOM object for the row before the tested position + * (or null for first position in the table). + * @param {?HTMLElement} nextRow + * DOM object for the row after the tested position + * (or null for last position in the table). + * + * @return {object} + * An object with the keys `min` and `max` to indicate the valid indent + * interval. + */ + Drupal.tableDrag.prototype.row.prototype.validIndentInterval = function (prevRow, nextRow) { + var $prevRow = $(prevRow); + var minIndent; + var maxIndent; + + // Minimum indentation: + // Do not orphan the next row. + minIndent = nextRow ? $(nextRow).find('.js-indentation').length : 0; + + // Maximum indentation: + if (!prevRow || $prevRow.is(':not(.draggable)') || $(this.element).is('.tabledrag-root')) { + // Do not indent: + // - the first row in the table, + // - rows dragged below a non-draggable row, + // - 'root' rows. + maxIndent = 0; + } + else { + // Do not go deeper than as a child of the previous row. + maxIndent = $prevRow.find('.js-indentation').length + ($prevRow.is('.tabledrag-leaf') ? 0 : 1); + // Limit by the maximum allowed depth for the table. + if (this.maxDepth) { + maxIndent = Math.min(maxIndent, this.maxDepth - (this.groupDepth - this.indents)); + } + } + + return {min: minIndent, max: maxIndent}; + }; + + /** + * Indent a row within the legal bounds of the table. + * + * @param {number} indentDiff + * The number of additional indentations proposed for the row (can be + * positive or negative). This number will be adjusted to nearest valid + * indentation level for the row. + * + * @return {number} + * The number of indentations applied. + */ + Drupal.tableDrag.prototype.row.prototype.indent = function (indentDiff) { + var $group = $(this.group); + // Determine the valid indentations interval if not available yet. + if (!this.interval) { + var prevRow = $(this.element).prev('tr').get(0); + var nextRow = $group.eq(-1).next('tr').get(0); + this.interval = this.validIndentInterval(prevRow, nextRow); + } + + // Adjust to the nearest valid indentation. + var indent = this.indents + indentDiff; + indent = Math.max(indent, this.interval.min); + indent = Math.min(indent, this.interval.max); + indentDiff = indent - this.indents; + + for (var n = 1; n <= Math.abs(indentDiff); n++) { + // Add or remove indentations. + if (indentDiff < 0) { + $group.find('.js-indentation:first-of-type').remove(); + this.indents--; + } + else { + $group.find('td:first-of-type').prepend(Drupal.theme('tableDragIndentation')); + this.indents++; + } + } + if (indentDiff) { + // Update indentation for this row. + this.changed = true; + this.groupDepth += indentDiff; + this.onIndent(); + } + + return indentDiff; + }; + + /** + * Find all siblings for a row. + * + * According to its subgroup or indentation. Note that the passed-in row is + * included in the list of siblings. + * + * @param {object} rowSettings + * The field settings we're using to identify what constitutes a sibling. + * + * @return {Array} + * An array of siblings. + */ + Drupal.tableDrag.prototype.row.prototype.findSiblings = function (rowSettings) { + var siblings = []; + var directions = ['prev', 'next']; + var rowIndentation = this.indents; + var checkRowIndentation; + for (var d = 0; d < directions.length; d++) { + var checkRow = $(this.element)[directions[d]](); + while (checkRow.length) { + // Check that the sibling contains a similar target field. + if (checkRow.find('.' + rowSettings.target)) { + // Either add immediately if this is a flat table, or check to ensure + // that this row has the same level of indentation. + if (this.indentEnabled) { + checkRowIndentation = checkRow.find('.js-indentation').length; + } + + if (!(this.indentEnabled) || (checkRowIndentation === rowIndentation)) { + siblings.push(checkRow[0]); + } + else if (checkRowIndentation < rowIndentation) { + // No need to keep looking for siblings when we get to a parent. + break; + } + } + else { + break; + } + checkRow = checkRow[directions[d]](); + } + // Since siblings are added in reverse order for previous, reverse the + // completed list of previous siblings. Add the current row and continue. + if (directions[d] === 'prev') { + siblings.reverse(); + siblings.push(this.element); + } + } + return siblings; + }; + + /** + * Remove indentation helper classes from the current row group. + */ + Drupal.tableDrag.prototype.row.prototype.removeIndentClasses = function () { + for (var n in this.children) { + if (this.children.hasOwnProperty(n)) { + $(this.children[n]).find('.js-indentation') + .removeClass('tree-child') + .removeClass('tree-child-first') + .removeClass('tree-child-last') + .removeClass('tree-child-horizontal'); + } + } + }; + + /** + * Add an asterisk or other marker to the changed row. + */ + Drupal.tableDrag.prototype.row.prototype.markChanged = function () { + var marker = Drupal.theme('tableDragChangedMarker'); + var cell = $(this.element).find('td:first-of-type'); + if (cell.find('abbr.tabledrag-changed').length === 0) { + cell.append(marker); + } + }; + + /** + * Stub function. Allows a custom handler when a row is indented. + * + * @return {null} + * Returns null when the stub function is used. + */ + Drupal.tableDrag.prototype.row.prototype.onIndent = function () { + return null; + }; + + /** + * Stub function. Allows a custom handler when a row is swapped. + * + * @param {HTMLElement} swappedRow + * The element for the swapped row. + * + * @return {null} + * Returns null when the stub function is used. + */ + Drupal.tableDrag.prototype.row.prototype.onSwap = function (swappedRow) { + return null; + }; + + $.extend(Drupal.theme, /** @lends Drupal.theme */{ + + /** + * @return {string} + * Markup for the marker. + */ + tableDragChangedMarker: function () { + return '<abbr class="warning tabledrag-changed" title="' + Drupal.t('Changed') + '">*</abbr>'; + }, + + /** + * @return {string} + * Markup for the indentation. + */ + tableDragIndentation: function () { + return '<div class="js-indentation indentation"> </div>'; + }, + + /** + * @return {string} + * Markup for the warning. + */ + tableDragChangedWarning: function () { + return '<div class="tabledrag-changed-warning messages messages--warning" role="alert">' + Drupal.theme('tableDragChangedMarker') + ' ' + Drupal.t('You have unsaved changes.') + '</div>'; + } + }); + +})(jQuery, Drupal, drupalSettings); diff --git a/core/misc/tabledrag.js b/core/misc/tabledrag.js index 75468e60ddb4..550c7a6bc88d 100644 --- a/core/misc/tabledrag.js +++ b/core/misc/tabledrag.js @@ -1,45 +1,21 @@ /** - * @file - * Provide dragging capabilities to admin uis. - */ - -/** - * Triggers when weights columns are toggled. - * - * @event columnschange - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/tabledrag.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Store the state of weight columns display for all tables. - * - * Default value is to hide weight columns. - */ var showWeight = JSON.parse(localStorage.getItem('Drupal.tableDrag.showWeight')); - /** - * Drag and drop table rows with field manipulation. - * - * Using the drupal_attach_tabledrag() function, any table with weights or - * parent relationships may be made into draggable tables. Columns containing - * a field may optionally be hidden, providing a better user experience. - * - * Created tableDrag instances may be modified with custom behaviors by - * overriding the .onDrag, .onDrop, .row.onSwap, and .row.onIndent methods. - * See blocks.js for an example of adding additional functionality to - * tableDrag. - * - * @type {Drupal~behavior} - */ Drupal.behaviors.tableDrag = { - attach: function (context, settings) { + attach: function attach(context, settings) { function initTableDrag(table, base) { if (table.length) { - // Create the new tableDrag instance. Save in the Drupal variable - // to allow other scripts access to the object. Drupal.tableDrag[base] = new Drupal.tableDrag(table[0], settings.tableDrag[base]); } } @@ -52,128 +28,40 @@ } }; - /** - * Provides table and field manipulation. - * - * @constructor - * - * @param {HTMLElement} table - * DOM object for the table to be made draggable. - * @param {object} tableSettings - * Settings for the table added via drupal_add_dragtable(). - */ Drupal.tableDrag = function (table, tableSettings) { var self = this; var $table = $(table); - /** - * @type {jQuery} - */ this.$table = $(table); - /** - * - * @type {HTMLElement} - */ this.table = table; - /** - * @type {object} - */ this.tableSettings = tableSettings; - /** - * Used to hold information about a current drag operation. - * - * @type {?HTMLElement} - */ this.dragObject = null; - /** - * Provides operations for row manipulation. - * - * @type {?HTMLElement} - */ this.rowObject = null; - /** - * Remember the previous element. - * - * @type {?HTMLElement} - */ this.oldRowElement = null; - /** - * Used to determine up or down direction from last mouse move. - * - * @type {number} - */ this.oldY = 0; - /** - * Whether anything in the entire table has changed. - * - * @type {bool} - */ this.changed = false; - /** - * Maximum amount of allowed parenting. - * - * @type {number} - */ this.maxDepth = 0; - /** - * Direction of the table. - * - * @type {number} - */ this.rtl = $(this.table).css('direction') === 'rtl' ? -1 : 1; - /** - * - * @type {bool} - */ this.striping = $(this.table).data('striping') === 1; - /** - * Configure the scroll settings. - * - * @type {object} - * - * @prop {number} amount - * @prop {number} interval - * @prop {number} trigger - */ - this.scrollSettings = {amount: 4, interval: 50, trigger: 70}; - - /** - * - * @type {?number} - */ + this.scrollSettings = { amount: 4, interval: 50, trigger: 70 }; + this.scrollInterval = null; - /** - * - * @type {number} - */ this.scrollY = 0; - /** - * - * @type {number} - */ this.windowHeight = 0; - /** - * Check this table's settings for parent relationships. - * - * For efficiency, large sections of code can be skipped if we don't need to - * track horizontal movement and indentations. - * - * @type {bool} - */ this.indentEnabled = false; for (var group in tableSettings) { if (tableSettings.hasOwnProperty(group)) { @@ -190,77 +78,49 @@ } } if (this.indentEnabled) { - - /** - * Total width of indents, set in makeDraggable. - * - * @type {number} - */ this.indentCount = 1; - // Find the width of indentations to measure mouse movements against. - // Because the table doesn't need to start with any indentations, we - // manually append 2 indentations in the first draggable row, measure - // the offset, then remove. + var indent = Drupal.theme('tableDragIndentation'); var testRow = $('<tr/>').addClass('draggable').appendTo(table); var testCell = $('<td/>').appendTo(testRow).prepend(indent).prepend(indent); var $indentation = testCell.find('.js-indentation'); - /** - * - * @type {number} - */ this.indentAmount = $indentation.get(1).offsetLeft - $indentation.get(0).offsetLeft; testRow.remove(); } - // Make each applicable row draggable. - // Match immediate children of the parent element to allow nesting. - $table.find('> tr.draggable, > tbody > tr.draggable').each(function () { self.makeDraggable(this); }); - - // Add a link before the table for users to show or hide weight columns. - $table.before($('<button type="button" class="link tabledrag-toggle-weight"></button>') - .attr('title', Drupal.t('Re-order rows by numerical weight instead of dragging.')) - .on('click', $.proxy(function (e) { - e.preventDefault(); - this.toggleColumns(); - }, this)) - .wrap('<div class="tabledrag-toggle-weight-wrapper"></div>') - .parent() - ); - - // Initialize the specified columns (for example, weight or parent columns) - // to show or hide according to user preference. This aids accessibility - // so that, e.g., screen reader users can choose to enter weight values and - // manipulate form elements directly, rather than using drag-and-drop.. + $table.find('> tr.draggable, > tbody > tr.draggable').each(function () { + self.makeDraggable(this); + }); + + $table.before($('<button type="button" class="link tabledrag-toggle-weight"></button>').attr('title', Drupal.t('Re-order rows by numerical weight instead of dragging.')).on('click', $.proxy(function (e) { + e.preventDefault(); + this.toggleColumns(); + }, this)).wrap('<div class="tabledrag-toggle-weight-wrapper"></div>').parent()); + self.initColumns(); - // Add event bindings to the document. The self variable is passed along - // as event handlers do not have direct access to the tableDrag object. - $(document).on('touchmove', function (event) { return self.dragRow(event.originalEvent.touches[0], self); }); - $(document).on('touchend', function (event) { return self.dropRow(event.originalEvent.touches[0], self); }); - $(document).on('mousemove pointermove', function (event) { return self.dragRow(event, self); }); - $(document).on('mouseup pointerup', function (event) { return self.dropRow(event, self); }); + $(document).on('touchmove', function (event) { + return self.dragRow(event.originalEvent.touches[0], self); + }); + $(document).on('touchend', function (event) { + return self.dropRow(event.originalEvent.touches[0], self); + }); + $(document).on('mousemove pointermove', function (event) { + return self.dragRow(event, self); + }); + $(document).on('mouseup pointerup', function (event) { + return self.dropRow(event, self); + }); - // React to localStorage event showing or hiding weight columns. $(window).on('storage', $.proxy(function (e) { - // Only react to 'Drupal.tableDrag.showWeight' value change. if (e.originalEvent.key === 'Drupal.tableDrag.showWeight') { - // This was changed in another window, get the new value for this - // window. showWeight = JSON.parse(e.originalEvent.newValue); this.displayColumns(showWeight); } }, this)); }; - /** - * Initialize columns containing form elements to be hidden by default. - * - * Identify and mark each cell with a CSS class so we can easily toggle - * show/hide it. Finally, hide columns if user does not have a - * 'Drupal.tableDrag.showWeight' localStorage value. - */ Drupal.tableDrag.prototype.initColumns = function () { var $table = this.$table; var hidden; @@ -268,8 +128,6 @@ var columnIndex; for (var group in this.tableSettings) { if (this.tableSettings.hasOwnProperty(group)) { - - // Find the first field in this group. for (var d in this.tableSettings[group]) { if (this.tableSettings[group].hasOwnProperty(d)) { var field = $table.find('.' + this.tableSettings[group][d].target).eq(0); @@ -281,11 +139,7 @@ } } - // Mark the column containing this field so it can be hidden. if (hidden && cell[0]) { - // Add 1 to our indexes. The nth-child selector is 1 based, not 0 - // based. Match immediate children of the parent element to allow - // nesting. columnIndex = cell.parent().find('> td').index(cell.get(0)) + 1; $table.find('> thead > tr, > tbody > tr, > tr').each(this.addColspanClass(columnIndex)); } @@ -294,20 +148,8 @@ this.displayColumns(showWeight); }; - /** - * Mark cells that have colspan. - * - * In order to adjust the colspan instead of hiding them altogether. - * - * @param {number} columnIndex - * The column index to add colspan class to. - * - * @return {function} - * Function to add colspan class. - */ Drupal.tableDrag.prototype.addColspanClass = function (columnIndex) { return function () { - // Get the columnIndex and adjust for any colspans in this row. var $row = $(this); var index = columnIndex; var cells = $row.children(); @@ -320,105 +162,62 @@ if (index > 0) { cell = cells.filter(':nth-child(' + index + ')'); if (cell[0].colSpan && cell[0].colSpan > 1) { - // If this cell has a colspan, mark it so we can reduce the colspan. cell.addClass('tabledrag-has-colspan'); - } - else { - // Mark this cell so we can hide it. + } else { cell.addClass('tabledrag-hide'); } } }; }; - /** - * Hide or display weight columns. Triggers an event on change. - * - * @fires event:columnschange - * - * @param {bool} displayWeight - * 'true' will show weight columns. - */ Drupal.tableDrag.prototype.displayColumns = function (displayWeight) { if (displayWeight) { this.showColumns(); - } - // Default action is to hide columns. - else { - this.hideColumns(); - } - // Trigger an event to allow other scripts to react to this display change. - // Force the extra parameter as a bool. + } else { + this.hideColumns(); + } + $('table').findOnce('tabledrag').trigger('columnschange', !!displayWeight); }; - /** - * Toggle the weight column depending on 'showWeight' value. - * - * Store only default override. - */ Drupal.tableDrag.prototype.toggleColumns = function () { showWeight = !showWeight; this.displayColumns(showWeight); if (showWeight) { - // Save default override. localStorage.setItem('Drupal.tableDrag.showWeight', showWeight); - } - else { - // Reset the value to its default. + } else { localStorage.removeItem('Drupal.tableDrag.showWeight'); } }; - /** - * Hide the columns containing weight/parent form elements. - * - * Undo showColumns(). - */ Drupal.tableDrag.prototype.hideColumns = function () { var $tables = $('table').findOnce('tabledrag'); - // Hide weight/parent cells and headers. + $tables.find('.tabledrag-hide').css('display', 'none'); - // Show TableDrag handles. + $tables.find('.tabledrag-handle').css('display', ''); - // Reduce the colspan of any effected multi-span columns. + $tables.find('.tabledrag-has-colspan').each(function () { this.colSpan = this.colSpan - 1; }); - // Change link text. + $('.tabledrag-toggle-weight').text(Drupal.t('Show row weights')); }; - /** - * Show the columns containing weight/parent form elements. - * - * Undo hideColumns(). - */ Drupal.tableDrag.prototype.showColumns = function () { var $tables = $('table').findOnce('tabledrag'); - // Show weight/parent cells and headers. + $tables.find('.tabledrag-hide').css('display', ''); - // Hide TableDrag handles. + $tables.find('.tabledrag-handle').css('display', 'none'); - // Increase the colspan for any columns where it was previously reduced. + $tables.find('.tabledrag-has-colspan').each(function () { this.colSpan = this.colSpan + 1; }); - // Change link text. + $('.tabledrag-toggle-weight').text(Drupal.t('Hide row weights')); }; - /** - * Find the target used within a particular row and group. - * - * @param {string} group - * Group selector. - * @param {HTMLElement} row - * The row HTML element. - * - * @return {object} - * The table row settings. - */ Drupal.tableDrag.prototype.rowSettings = function (group, row) { var field = $(row).find('.' + group); var tableSettingsGroup = this.tableSettings[group]; @@ -426,7 +225,6 @@ if (tableSettingsGroup.hasOwnProperty(delta)) { var targetClass = tableSettingsGroup[delta].target; if (field.is('.' + targetClass)) { - // Return a copy of the row settings. var rowSettings = {}; for (var n in tableSettingsGroup[delta]) { if (tableSettingsGroup[delta].hasOwnProperty(n)) { @@ -439,27 +237,20 @@ } }; - /** - * Take an item and add event handlers to make it become draggable. - * - * @param {HTMLElement} item - * The item to add event handlers to. - */ Drupal.tableDrag.prototype.makeDraggable = function (item) { var self = this; var $item = $(item); - // Add a class to the title link. + $item.find('td:first-of-type').find('a').addClass('menu-item__link'); - // Create the handle. + var handle = $('<a href="#" class="tabledrag-handle"><div class="handle"> </div></a>').attr('title', Drupal.t('Drag to re-order')); - // Insert the handle after indentations (if any). + var $indentationLast = $item.find('td:first-of-type').find('.js-indentation').eq(-1); if ($indentationLast.length) { $indentationLast.after(handle); - // Update the total width of indentation in this entire table. + self.indentCount = Math.max($item.find('.js-indentation').length, self.indentCount); - } - else { + } else { $item.find('td').eq(0).prepend(handle); } @@ -471,27 +262,21 @@ self.dragStart(event, self, item); }); - // Prevent the anchor tag from jumping us to the top of the page. handle.on('click', function (e) { e.preventDefault(); }); - // Set blur cleanup when a handle is focused. handle.on('focus', function () { self.safeBlur = true; }); - // On blur, fire the same function as a touchend/mouseup. This is used to - // update values after a row has been moved through the keyboard support. handle.on('blur', function (event) { if (self.rowObject && self.safeBlur) { self.dropRow(event, self); } }); - // Add arrow-key support to the handle. handle.on('keydown', function (event) { - // If a rowObject doesn't yet exist and this isn't the tab key. if (event.keyCode !== 9 && !self.rowObject) { self.rowObject = new self.row(item, 'keyboard', self.indentEnabled, self.maxDepth, true); } @@ -499,20 +284,14 @@ var keyChange = false; var groupHeight; - /* eslint-disable no-fallthrough */ - switch (event.keyCode) { - // Left arrow. case 37: - // Safari left arrow. case 63234: keyChange = true; self.rowObject.indent(-1 * self.rtl); break; - // Up arrow. case 38: - // Safari up arrow. case 63232: var $previousRow = $(self.rowObject.element).prev('tr:first-of-type'); var previousRow = $previousRow.get(0); @@ -521,13 +300,11 @@ previousRow = $previousRow.get(0); } if (previousRow) { - // Do not allow the onBlur cleanup. self.safeBlur = false; self.rowObject.direction = 'up'; keyChange = true; if ($(item).is('.tabledrag-root')) { - // Swap with the previous top-level row. groupHeight = 0; while (previousRow && $previousRow.find('.js-indentation').length) { $previousRow = $(previousRow).prev('tr:first-of-type'); @@ -536,34 +313,27 @@ } if (previousRow) { self.rowObject.swap('before', previousRow); - // No need to check for indentation, 0 is the only valid one. + window.scrollBy(0, -groupHeight); } - } - else if (self.table.tBodies[0].rows[0] !== previousRow || $previousRow.is('.draggable')) { - // Swap with the previous row (unless previous row is the first - // one and undraggable). + } else if (self.table.tBodies[0].rows[0] !== previousRow || $previousRow.is('.draggable')) { self.rowObject.swap('before', previousRow); self.rowObject.interval = null; self.rowObject.indent(0); window.scrollBy(0, -parseInt(item.offsetHeight, 10)); } - // Regain focus after the DOM manipulation. + handle.trigger('focus'); } break; - // Right arrow. case 39: - // Safari right arrow. case 63235: keyChange = true; self.rowObject.indent(self.rtl); break; - // Down arrow. case 40: - // Safari down arrow. case 63233: var $nextRow = $(self.rowObject.group).eq(-1).next('tr:first-of-type'); var nextRow = $nextRow.get(0); @@ -572,13 +342,11 @@ nextRow = $nextRow.get(0); } if (nextRow) { - // Do not allow the onBlur cleanup. self.safeBlur = false; self.rowObject.direction = 'down'; keyChange = true; if ($(item).is('.tabledrag-root')) { - // Swap with the next group (necessarily a top-level one). groupHeight = 0; var nextGroup = new self.row(nextRow, 'keyboard', self.indentEnabled, self.maxDepth, false); if (nextGroup) { @@ -587,25 +355,21 @@ }); var nextGroupRow = $(nextGroup.group).eq(-1).get(0); self.rowObject.swap('after', nextGroupRow); - // No need to check for indentation, 0 is the only valid one. + window.scrollBy(0, parseInt(groupHeight, 10)); } - } - else { - // Swap with the next row. + } else { self.rowObject.swap('after', nextRow); self.rowObject.interval = null; self.rowObject.indent(0); window.scrollBy(0, parseInt(item.offsetHeight, 10)); } - // Regain focus after the DOM manipulation. + handle.trigger('focus'); } break; } - /* eslint-enable no-fallthrough */ - if (self.rowObject && self.rowObject.changed === true) { $(item).addClass('drag'); if (self.oldRowElement) { @@ -618,49 +382,24 @@ self.onDrag(); } - // Returning false if we have an arrow key to prevent scrolling. if (keyChange) { return false; } }); - // Compatibility addition, return false on keypress to prevent unwanted - // scrolling. IE and Safari will suppress scrolling on keydown, but all - // other browsers need to return false on keypress. - // http://www.quirksmode.org/js/keys.html handle.on('keypress', function (event) { - /* eslint-disable no-fallthrough */ - switch (event.keyCode) { - // Left arrow. case 37: - // Up arrow. case 38: - // Right arrow. case 39: - // Down arrow. case 40: return false; } - - /* eslint-enable no-fallthrough */ - }); }; - /** - * Pointer event initiator, creates drag object and information. - * - * @param {jQuery.Event} event - * The event object that trigger the drag. - * @param {Drupal.tableDrag} self - * The drag handle. - * @param {HTMLElement} item - * The item that that is being dragged. - */ Drupal.tableDrag.prototype.dragStart = function (event, self, item) { - // Create a new dragObject recording the pointer information. self.dragObject = {}; self.dragObject.initOffset = self.getPointerOffset(item, event); self.dragObject.initPointerCoords = self.pointerCoords(event); @@ -668,66 +407,47 @@ self.dragObject.indentPointerPos = self.dragObject.initPointerCoords; } - // If there's a lingering row object from the keyboard, remove its focus. if (self.rowObject) { $(self.rowObject.element).find('a.tabledrag-handle').trigger('blur'); } - // Create a new rowObject for manipulation of this row. self.rowObject = new self.row(item, 'pointer', self.indentEnabled, self.maxDepth, true); - // Save the position of the table. self.table.topY = $(self.table).offset().top; self.table.bottomY = self.table.topY + self.table.offsetHeight; - // Add classes to the handle and row. $(item).addClass('drag'); - // Set the document to use the move cursor during drag. $('body').addClass('drag'); if (self.oldRowElement) { $(self.oldRowElement).removeClass('drag-previous'); } }; - /** - * Pointer movement handler, bound to document. - * - * @param {jQuery.Event} event - * The pointer event. - * @param {Drupal.tableDrag} self - * The tableDrag instance. - * - * @return {bool|undefined} - * Undefined if no dragObject is defined, false otherwise. - */ Drupal.tableDrag.prototype.dragRow = function (event, self) { if (self.dragObject) { self.currentPointerCoords = self.pointerCoords(event); var y = self.currentPointerCoords.y - self.dragObject.initOffset.y; var x = self.currentPointerCoords.x - self.dragObject.initOffset.x; - // Check for row swapping and vertical scrolling. if (y !== self.oldY) { self.rowObject.direction = y > self.oldY ? 'down' : 'up'; - // Update the old value. + self.oldY = y; - // Check if the window should be scrolled (and how fast). + var scrollAmount = self.checkScroll(self.currentPointerCoords.y); - // Stop any current scrolling. + clearInterval(self.scrollInterval); - // Continue scrolling if the mouse has moved in the scroll direction. + if (scrollAmount > 0 && self.rowObject.direction === 'down' || scrollAmount < 0 && self.rowObject.direction === 'up') { self.setScroll(scrollAmount); } - // If we have a valid target, perform the swap and restripe the table. var currentRow = self.findDropTargetRow(x, y); if (currentRow) { if (self.rowObject.direction === 'down') { self.rowObject.swap('after', currentRow, self); - } - else { + } else { self.rowObject.swap('before', currentRow, self); } if (self.striping === true) { @@ -736,16 +456,13 @@ } } - // Similar to row swapping, handle indentations. if (self.indentEnabled) { var xDiff = self.currentPointerCoords.x - self.dragObject.indentPointerPos.x; - // Set the number of indentations the pointer has been moved left or - // right. + var indentDiff = Math.round(xDiff / self.indentAmount); - // Indent the row with our estimated diff, which may be further - // restricted according to the rows around this row. + var indentChange = self.rowObject.indent(indentDiff); - // Update table and pointer indentations. + self.dragObject.indentPointerPos.x += self.indentAmount * indentChange * self.rtl; self.indentCount = Math.max(self.indentCount, self.rowObject.indents); } @@ -754,29 +471,17 @@ } }; - /** - * Pointerup behavior. - * - * @param {jQuery.Event} event - * The pointer event. - * @param {Drupal.tableDrag} self - * The tableDrag instance. - */ Drupal.tableDrag.prototype.dropRow = function (event, self) { var droppedRow; var $droppedRow; - // Drop row functionality. if (self.rowObject !== null) { droppedRow = self.rowObject.element; $droppedRow = $(droppedRow); - // The row is already in the right place so we just release it. + if (self.rowObject.changed === true) { - // Update the fields in the dropped row. self.updateFields(droppedRow); - // If a setting exists for affecting the entire group, update all the - // fields in the entire dragged group. for (var group in self.tableSettings) { if (self.tableSettings.hasOwnProperty(group)) { var rowSettings = self.rowSettings(group, droppedRow); @@ -809,7 +514,6 @@ self.rowObject = null; } - // Functionality specific only to pointerup events. if (self.dragObject !== null) { self.dragObject = null; $('body').removeClass('drag'); @@ -817,18 +521,9 @@ } }; - /** - * Get the coordinates from the event (allowing for browser differences). - * - * @param {jQuery.Event} event - * The pointer event. - * - * @return {object} - * An object with `x` and `y` keys indicating the position. - */ Drupal.tableDrag.prototype.pointerCoords = function (event) { if (event.pageX || event.pageY) { - return {x: event.pageX, y: event.pageY}; + return { x: event.pageX, y: event.pageY }; } return { x: event.clientX + document.body.scrollLeft - document.body.clientLeft, @@ -836,39 +531,12 @@ }; }; - /** - * Get the event offset from the target element. - * - * Given a target element and a pointer event, get the event offset from that - * element. To do this we need the element's position and the target position. - * - * @param {HTMLElement} target - * The target HTML element. - * @param {jQuery.Event} event - * The pointer event. - * - * @return {object} - * An object with `x` and `y` keys indicating the position. - */ Drupal.tableDrag.prototype.getPointerOffset = function (target, event) { var docPos = $(target).offset(); var pointerPos = this.pointerCoords(event); - return {x: pointerPos.x - docPos.left, y: pointerPos.y - docPos.top}; + return { x: pointerPos.x - docPos.left, y: pointerPos.y - docPos.top }; }; - /** - * Find the row the mouse is currently over. - * - * This row is then taken and swapped with the one being dragged. - * - * @param {number} x - * The x coordinate of the mouse on the page (not the screen). - * @param {number} y - * The y coordinate of the mouse on the page (not the screen). - * - * @return {*} - * The drop target row, if found. - */ Drupal.tableDrag.prototype.findDropTargetRow = function (x, y) { var rows = $(this.table.tBodies[0].rows).not(':hidden'); for (var n = 0; n < rows.length; n++) { @@ -876,42 +544,30 @@ var $row = $(row); var rowY = $row.offset().top; var rowHeight; - // Because Safari does not report offsetHeight on table rows, but does on - // table cells, grab the firstChild of the row and use that instead. - // http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari. + if (row.offsetHeight === 0) { rowHeight = parseInt(row.firstChild.offsetHeight, 10) / 2; - } - // Other browsers. - else { - rowHeight = parseInt(row.offsetHeight, 10) / 2; - } + } else { + rowHeight = parseInt(row.offsetHeight, 10) / 2; + } - // Because we always insert before, we need to offset the height a bit. - if ((y > (rowY - rowHeight)) && (y < (rowY + rowHeight))) { + if (y > rowY - rowHeight && y < rowY + rowHeight) { if (this.indentEnabled) { - // Check that this row is not a child of the row being dragged. for (n in this.rowObject.group) { if (this.rowObject.group[n] === row) { return null; } } - } - else { - // Do not allow a row to be swapped with itself. + } else { if (row === this.rowObject.element) { return null; } } - // Check that swapping with this row is allowed. if (!this.rowObject.isValidSwap(row)) { return null; } - // We may have found the row the mouse just passed over, but it doesn't - // take into account hidden rows. Skip backwards until we find a - // draggable row. while ($row.is(':hidden') && $row.prev('tr').is(':hidden')) { $row = $row.prev('tr:first-of-type'); row = $row.get(0); @@ -922,30 +578,14 @@ return null; }; - /** - * After the row is dropped, update the table fields. - * - * @param {HTMLElement} changedRow - * DOM object for the row that was just dropped. - */ Drupal.tableDrag.prototype.updateFields = function (changedRow) { for (var group in this.tableSettings) { if (this.tableSettings.hasOwnProperty(group)) { - // Each group may have a different setting for relationship, so we find - // the source rows for each separately. this.updateField(changedRow, group); } } }; - /** - * After the row is dropped, update a single table field. - * - * @param {HTMLElement} changedRow - * DOM object for the row that was just dropped. - * @param {string} group - * The settings group on which field updates will occur. - */ Drupal.tableDrag.prototype.updateField = function (changedRow, group) { var rowSettings = this.rowSettings(group, changedRow); var $changedRow = $(changedRow); @@ -953,72 +593,54 @@ var $previousRow; var previousRow; var useSibling; - // Set the row as its own target. + if (rowSettings.relationship === 'self' || rowSettings.relationship === 'group') { sourceRow = changedRow; - } - // Siblings are easy, check previous and next rows. - else if (rowSettings.relationship === 'sibling') { - $previousRow = $changedRow.prev('tr:first-of-type'); - previousRow = $previousRow.get(0); - var $nextRow = $changedRow.next('tr:first-of-type'); - var nextRow = $nextRow.get(0); - sourceRow = changedRow; - if ($previousRow.is('.draggable') && $previousRow.find('.' + group).length) { - if (this.indentEnabled) { - if ($previousRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) { + } else if (rowSettings.relationship === 'sibling') { + $previousRow = $changedRow.prev('tr:first-of-type'); + previousRow = $previousRow.get(0); + var $nextRow = $changedRow.next('tr:first-of-type'); + var nextRow = $nextRow.get(0); + sourceRow = changedRow; + if ($previousRow.is('.draggable') && $previousRow.find('.' + group).length) { + if (this.indentEnabled) { + if ($previousRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) { + sourceRow = previousRow; + } + } else { sourceRow = previousRow; } - } - else { - sourceRow = previousRow; - } - } - else if ($nextRow.is('.draggable') && $nextRow.find('.' + group).length) { - if (this.indentEnabled) { - if ($nextRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) { + } else if ($nextRow.is('.draggable') && $nextRow.find('.' + group).length) { + if (this.indentEnabled) { + if ($nextRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) { + sourceRow = nextRow; + } + } else { sourceRow = nextRow; } } - else { - sourceRow = nextRow; - } - } - } - // Parents, look up the tree until we find a field not in this group. - // Go up as many parents as indentations in the changed row. - else if (rowSettings.relationship === 'parent') { - $previousRow = $changedRow.prev('tr'); - previousRow = $previousRow; - while ($previousRow.length && $previousRow.find('.js-indentation').length >= this.rowObject.indents) { - $previousRow = $previousRow.prev('tr'); - previousRow = $previousRow; - } - // If we found a row. - if ($previousRow.length) { - sourceRow = $previousRow.get(0); - } - // Otherwise we went all the way to the left of the table without finding - // a parent, meaning this item has been placed at the root level. - else { - // Use the first row in the table as source, because it's guaranteed to - // be at the root level. Find the first item, then compare this row - // against it as a sibling. - sourceRow = $(this.table).find('tr.draggable:first-of-type').get(0); - if (sourceRow === this.rowObject.element) { - sourceRow = $(this.rowObject.group[this.rowObject.group.length - 1]).next('tr.draggable').get(0); + } else if (rowSettings.relationship === 'parent') { + $previousRow = $changedRow.prev('tr'); + previousRow = $previousRow; + while ($previousRow.length && $previousRow.find('.js-indentation').length >= this.rowObject.indents) { + $previousRow = $previousRow.prev('tr'); + previousRow = $previousRow; + } + + if ($previousRow.length) { + sourceRow = $previousRow.get(0); + } else { + sourceRow = $(this.table).find('tr.draggable:first-of-type').get(0); + if (sourceRow === this.rowObject.element) { + sourceRow = $(this.rowObject.group[this.rowObject.group.length - 1]).next('tr.draggable').get(0); + } + useSibling = true; + } } - useSibling = true; - } - } - // Because we may have moved the row from one category to another, - // take a look at our sibling and borrow its sources and targets. this.copyDragClasses(sourceRow, changedRow, group); rowSettings = this.rowSettings(group, changedRow); - // In the case that we're looking for a parent, but the row is at the top - // of the tree, copy our sibling's values. if (useSibling) { rowSettings.relationship = 'sibling'; rowSettings.source = rowSettings.target; @@ -1027,44 +649,35 @@ var targetClass = '.' + rowSettings.target; var targetElement = $changedRow.find(targetClass).get(0); - // Check if a target element exists in this row. if (targetElement) { var sourceClass = '.' + rowSettings.source; var sourceElement = $(sourceClass, sourceRow).get(0); switch (rowSettings.action) { case 'depth': - // Get the depth of the target row. targetElement.value = $(sourceElement).closest('tr').find('.js-indentation').length; break; case 'match': - // Update the value. targetElement.value = sourceElement.value; break; case 'order': var siblings = this.rowObject.findSiblings(rowSettings); if ($(targetElement).is('select')) { - // Get a list of acceptable values. var values = []; $(targetElement).find('option').each(function () { values.push(this.value); }); var maxVal = values[values.length - 1]; - // Populate the values in the siblings. + $(siblings).find(targetClass).each(function () { - // If there are more items than possible values, assign the - // maximum value to the row. if (values.length > 0) { this.value = values.shift(); - } - else { + } else { this.value = maxVal; } }); - } - else { - // Assume a numeric input field. + } else { var weight = parseInt($(siblings[0]).find(targetClass).val(), 10) || 0; $(siblings).find(targetClass).each(function () { this.value = weight; @@ -1076,20 +689,6 @@ } }; - /** - * Copy all tableDrag related classes from one row to another. - * - * Copy all special tableDrag classes from one row's form elements to a - * different one, removing any special classes that the destination row - * may have had. - * - * @param {HTMLElement} sourceRow - * The element for the source row. - * @param {HTMLElement} targetRow - * The element for the target row. - * @param {string} group - * The group selector. - */ Drupal.tableDrag.prototype.copyDragClasses = function (sourceRow, targetRow, group) { var sourceElement = $(sourceRow).find('.' + group); var targetElement = $(targetRow).find('.' + group); @@ -1098,15 +697,6 @@ } }; - /** - * Check the suggested scroll of the table. - * - * @param {number} cursorY - * The Y position of the cursor. - * - * @return {number} - * The suggested scroll. - */ Drupal.tableDrag.prototype.checkScroll = function (cursorY) { var de = document.documentElement; var b = document.body; @@ -1115,37 +705,27 @@ var scrollY; if (document.all) { scrollY = this.scrollY = !de.scrollTop ? b.scrollTop : de.scrollTop; - } - else { + } else { scrollY = this.scrollY = window.pageYOffset ? window.pageYOffset : window.scrollY; } var trigger = this.scrollSettings.trigger; var delta = 0; - // Return a scroll speed relative to the edge of the screen. if (cursorY - scrollY > windowHeight - trigger) { delta = trigger / (windowHeight + scrollY - cursorY); - delta = (delta > 0 && delta < trigger) ? delta : trigger; + delta = delta > 0 && delta < trigger ? delta : trigger; return delta * this.scrollSettings.amount; - } - else if (cursorY - scrollY < trigger) { + } else if (cursorY - scrollY < trigger) { delta = trigger / (cursorY - scrollY); - delta = (delta > 0 && delta < trigger) ? delta : trigger; + delta = delta > 0 && delta < trigger ? delta : trigger; return -delta * this.scrollSettings.amount; } }; - /** - * Set the scroll for the table. - * - * @param {number} scrollAmount - * The amount of scroll to apply to the window. - */ Drupal.tableDrag.prototype.setScroll = function (scrollAmount) { var self = this; this.scrollInterval = setInterval(function () { - // Update the scroll values stored in the object. self.checkScroll(self.currentPointerCoords.y); var aboveTable = self.scrollY > self.table.topY; var belowTable = self.scrollY + self.windowHeight < self.table.bottomY; @@ -1155,55 +735,18 @@ }, this.scrollSettings.interval); }; - /** - * Command to restripe table properly. - */ Drupal.tableDrag.prototype.restripeTable = function () { - // :even and :odd are reversed because jQuery counts from 0 and - // we count from 1, so we're out of sync. - // Match immediate children of the parent element to allow nesting. - $(this.table).find('> tbody > tr.draggable, > tr.draggable') - .filter(':visible') - .filter(':odd').removeClass('odd').addClass('even').end() - .filter(':even').removeClass('even').addClass('odd'); + $(this.table).find('> tbody > tr.draggable, > tr.draggable').filter(':visible').filter(':odd').removeClass('odd').addClass('even').end().filter(':even').removeClass('even').addClass('odd'); }; - /** - * Stub function. Allows a custom handler when a row begins dragging. - * - * @return {null} - * Returns null when the stub function is used. - */ Drupal.tableDrag.prototype.onDrag = function () { return null; }; - /** - * Stub function. Allows a custom handler when a row is dropped. - * - * @return {null} - * Returns null when the stub function is used. - */ Drupal.tableDrag.prototype.onDrop = function () { return null; }; - /** - * Constructor to make a new object to manipulate a table row. - * - * @param {HTMLElement} tableRow - * The DOM element for the table row we will be manipulating. - * @param {string} method - * The method in which this row is being moved. Either 'keyboard' or - * 'mouse'. - * @param {bool} indentEnabled - * Whether the containing table uses indentations. Used for optimizations. - * @param {number} maxDepth - * The maximum amount of indentations this row may contain. - * @param {bool} addClasses - * Whether we want to add classes to this row to indicate child - * relationships. - */ Drupal.tableDrag.prototype.row = function (tableRow, method, indentEnabled, maxDepth, addClasses) { var $tableRow = $(tableRow); @@ -1215,29 +758,19 @@ this.table = $tableRow.closest('table')[0]; this.indentEnabled = indentEnabled; this.maxDepth = maxDepth; - // Direction the row is being moved. + this.direction = ''; if (this.indentEnabled) { this.indents = $tableRow.find('.js-indentation').length; this.children = this.findChildren(addClasses); this.group = $.merge(this.group, this.children); - // Find the depth of this entire group. + for (var n = 0; n < this.group.length; n++) { this.groupDepth = Math.max($(this.group[n]).find('.js-indentation').length, this.groupDepth); } } }; - /** - * Find all children of rowObject by indentation. - * - * @param {bool} addClasses - * Whether we want to add classes to this row to indicate child - * relationships. - * - * @return {Array} - * An array of children of the row. - */ Drupal.tableDrag.prototype.row.prototype.findChildren = function (addClasses) { var parentIndentation = this.indents; var currentRow = $(this.element, this.table).next('tr.draggable'); @@ -1246,27 +779,24 @@ function rowIndentation(indentNum, el) { var self = $(el); - if (child === 1 && (indentNum === parentIndentation)) { + if (child === 1 && indentNum === parentIndentation) { self.addClass('tree-child-first'); } if (indentNum === parentIndentation) { self.addClass('tree-child'); - } - else if (indentNum > parentIndentation) { + } else if (indentNum > parentIndentation) { self.addClass('tree-child-horizontal'); } } while (currentRow.length) { - // A greater indentation indicates this is a child. if (currentRow.find('.js-indentation').length > parentIndentation) { child++; rows.push(currentRow[0]); if (addClasses) { currentRow.find('.js-indentation').each(rowIndentation); } - } - else { + } else { break; } currentRow = currentRow.next('tr.draggable'); @@ -1277,15 +807,6 @@ return rows; }; - /** - * Ensure that two rows are allowed to be swapped. - * - * @param {HTMLElement} row - * DOM object for the row being considered for swapping. - * - * @return {bool} - * Whether the swap is a valid swap or not. - */ Drupal.tableDrag.prototype.row.prototype.isValidSwap = function (row) { var $row = $(row); if (this.indentEnabled) { @@ -1294,20 +815,17 @@ if (this.direction === 'down') { prevRow = row; nextRow = $row.next('tr').get(0); - } - else { + } else { prevRow = $row.prev('tr').get(0); nextRow = row; } this.interval = this.validIndentInterval(prevRow, nextRow); - // We have an invalid swap if the valid indentations interval is empty. if (this.interval.min > this.interval.max) { return false; } } - // Do not let an un-draggable first row have anything put before it. if (this.table.tBodies[0].rows[0] === row && $row.is(':not(.draggable)')) { return false; } @@ -1315,21 +833,12 @@ return true; }; - /** - * Perform the swap between two rows. - * - * @param {string} position - * Whether the swap will occur 'before' or 'after' the given row. - * @param {HTMLElement} row - * DOM element what will be swapped with the row group. - */ Drupal.tableDrag.prototype.row.prototype.swap = function (position, row) { - // Makes sure only DOM object are passed to Drupal.detachBehaviors(). this.group.forEach(function (row) { Drupal.detachBehaviors(row, drupalSettings, 'move'); }); $(row)[position](this.group); - // Makes sure only DOM object are passed to Drupal.attachBehaviors()s. + this.group.forEach(function (row) { Drupal.attachBehaviors(row, drupalSettings); }); @@ -1337,88 +846,50 @@ this.onSwap(row); }; - /** - * Determine the valid indentations interval for the row at a given position. - * - * @param {?HTMLElement} prevRow - * DOM object for the row before the tested position - * (or null for first position in the table). - * @param {?HTMLElement} nextRow - * DOM object for the row after the tested position - * (or null for last position in the table). - * - * @return {object} - * An object with the keys `min` and `max` to indicate the valid indent - * interval. - */ Drupal.tableDrag.prototype.row.prototype.validIndentInterval = function (prevRow, nextRow) { var $prevRow = $(prevRow); var minIndent; var maxIndent; - // Minimum indentation: - // Do not orphan the next row. minIndent = nextRow ? $(nextRow).find('.js-indentation').length : 0; - // Maximum indentation: if (!prevRow || $prevRow.is(':not(.draggable)') || $(this.element).is('.tabledrag-root')) { - // Do not indent: - // - the first row in the table, - // - rows dragged below a non-draggable row, - // - 'root' rows. maxIndent = 0; - } - else { - // Do not go deeper than as a child of the previous row. + } else { maxIndent = $prevRow.find('.js-indentation').length + ($prevRow.is('.tabledrag-leaf') ? 0 : 1); - // Limit by the maximum allowed depth for the table. + if (this.maxDepth) { maxIndent = Math.min(maxIndent, this.maxDepth - (this.groupDepth - this.indents)); } } - return {min: minIndent, max: maxIndent}; + return { min: minIndent, max: maxIndent }; }; - /** - * Indent a row within the legal bounds of the table. - * - * @param {number} indentDiff - * The number of additional indentations proposed for the row (can be - * positive or negative). This number will be adjusted to nearest valid - * indentation level for the row. - * - * @return {number} - * The number of indentations applied. - */ Drupal.tableDrag.prototype.row.prototype.indent = function (indentDiff) { var $group = $(this.group); - // Determine the valid indentations interval if not available yet. + if (!this.interval) { var prevRow = $(this.element).prev('tr').get(0); var nextRow = $group.eq(-1).next('tr').get(0); this.interval = this.validIndentInterval(prevRow, nextRow); } - // Adjust to the nearest valid indentation. var indent = this.indents + indentDiff; indent = Math.max(indent, this.interval.min); indent = Math.min(indent, this.interval.max); indentDiff = indent - this.indents; for (var n = 1; n <= Math.abs(indentDiff); n++) { - // Add or remove indentations. if (indentDiff < 0) { $group.find('.js-indentation:first-of-type').remove(); this.indents--; - } - else { + } else { $group.find('td:first-of-type').prepend(Drupal.theme('tableDragIndentation')); this.indents++; } } if (indentDiff) { - // Update indentation for this row. this.changed = true; this.groupDepth += indentDiff; this.onIndent(); @@ -1427,18 +898,6 @@ return indentDiff; }; - /** - * Find all siblings for a row. - * - * According to its subgroup or indentation. Note that the passed-in row is - * included in the list of siblings. - * - * @param {object} rowSettings - * The field settings we're using to identify what constitutes a sibling. - * - * @return {Array} - * An array of siblings. - */ Drupal.tableDrag.prototype.row.prototype.findSiblings = function (rowSettings) { var siblings = []; var directions = ['prev', 'next']; @@ -1447,29 +906,22 @@ for (var d = 0; d < directions.length; d++) { var checkRow = $(this.element)[directions[d]](); while (checkRow.length) { - // Check that the sibling contains a similar target field. if (checkRow.find('.' + rowSettings.target)) { - // Either add immediately if this is a flat table, or check to ensure - // that this row has the same level of indentation. if (this.indentEnabled) { checkRowIndentation = checkRow.find('.js-indentation').length; } - if (!(this.indentEnabled) || (checkRowIndentation === rowIndentation)) { + if (!this.indentEnabled || checkRowIndentation === rowIndentation) { siblings.push(checkRow[0]); - } - else if (checkRowIndentation < rowIndentation) { - // No need to keep looking for siblings when we get to a parent. + } else if (checkRowIndentation < rowIndentation) { break; } - } - else { + } else { break; } checkRow = checkRow[directions[d]](); } - // Since siblings are added in reverse order for previous, reverse the - // completed list of previous siblings. Add the current row and continue. + if (directions[d] === 'prev') { siblings.reverse(); siblings.push(this.element); @@ -1478,24 +930,14 @@ return siblings; }; - /** - * Remove indentation helper classes from the current row group. - */ Drupal.tableDrag.prototype.row.prototype.removeIndentClasses = function () { for (var n in this.children) { if (this.children.hasOwnProperty(n)) { - $(this.children[n]).find('.js-indentation') - .removeClass('tree-child') - .removeClass('tree-child-first') - .removeClass('tree-child-last') - .removeClass('tree-child-horizontal'); + $(this.children[n]).find('.js-indentation').removeClass('tree-child').removeClass('tree-child-first').removeClass('tree-child-last').removeClass('tree-child-horizontal'); } } }; - /** - * Add an asterisk or other marker to the changed row. - */ Drupal.tableDrag.prototype.row.prototype.markChanged = function () { var marker = Drupal.theme('tableDragChangedMarker'); var cell = $(this.element).find('td:first-of-type'); @@ -1504,54 +946,25 @@ } }; - /** - * Stub function. Allows a custom handler when a row is indented. - * - * @return {null} - * Returns null when the stub function is used. - */ Drupal.tableDrag.prototype.row.prototype.onIndent = function () { return null; }; - /** - * Stub function. Allows a custom handler when a row is swapped. - * - * @param {HTMLElement} swappedRow - * The element for the swapped row. - * - * @return {null} - * Returns null when the stub function is used. - */ Drupal.tableDrag.prototype.row.prototype.onSwap = function (swappedRow) { return null; }; - $.extend(Drupal.theme, /** @lends Drupal.theme */{ - - /** - * @return {string} - * Markup for the marker. - */ - tableDragChangedMarker: function () { + $.extend(Drupal.theme, { + tableDragChangedMarker: function tableDragChangedMarker() { return '<abbr class="warning tabledrag-changed" title="' + Drupal.t('Changed') + '">*</abbr>'; }, - /** - * @return {string} - * Markup for the indentation. - */ - tableDragIndentation: function () { + tableDragIndentation: function tableDragIndentation() { return '<div class="js-indentation indentation"> </div>'; }, - /** - * @return {string} - * Markup for the warning. - */ - tableDragChangedWarning: function () { + tableDragChangedWarning: function tableDragChangedWarning() { return '<div class="tabledrag-changed-warning messages messages--warning" role="alert">' + Drupal.theme('tableDragChangedMarker') + ' ' + Drupal.t('You have unsaved changes.') + '</div>'; } }); - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/misc/tableheader.es6.js b/core/misc/tableheader.es6.js new file mode 100644 index 000000000000..8fc1b048963c --- /dev/null +++ b/core/misc/tableheader.es6.js @@ -0,0 +1,316 @@ +/** + * @file + * Sticky table headers. + */ + +(function ($, Drupal, displace) { + + 'use strict'; + + /** + * Attaches sticky table headers. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the sticky table header behavior. + */ + Drupal.behaviors.tableHeader = { + attach: function (context) { + $(window).one('scroll.TableHeaderInit', {context: context}, tableHeaderInitHandler); + } + }; + + function scrollValue(position) { + return document.documentElement[position] || document.body[position]; + } + + // Select and initialize sticky table headers. + function tableHeaderInitHandler(e) { + var $tables = $(e.data.context).find('table.sticky-enabled').once('tableheader'); + var il = $tables.length; + for (var i = 0; i < il; i++) { + TableHeader.tables.push(new TableHeader($tables[i])); + } + forTables('onScroll'); + } + + // Helper method to loop through tables and execute a method. + function forTables(method, arg) { + var tables = TableHeader.tables; + var il = tables.length; + for (var i = 0; i < il; i++) { + tables[i][method](arg); + } + } + + function tableHeaderResizeHandler(e) { + forTables('recalculateSticky'); + } + + function tableHeaderOnScrollHandler(e) { + forTables('onScroll'); + } + + function tableHeaderOffsetChangeHandler(e, offsets) { + forTables('stickyPosition', offsets.top); + } + + // Bind event that need to change all tables. + $(window).on({ + + /** + * When resizing table width can change, recalculate everything. + * + * @ignore + */ + 'resize.TableHeader': tableHeaderResizeHandler, + + /** + * Bind only one event to take care of calling all scroll callbacks. + * + * @ignore + */ + 'scroll.TableHeader': tableHeaderOnScrollHandler + }); + // Bind to custom Drupal events. + $(document).on({ + + /** + * Recalculate columns width when window is resized and when show/hide + * weight is triggered. + * + * @ignore + */ + 'columnschange.TableHeader': tableHeaderResizeHandler, + + /** + * Recalculate TableHeader.topOffset when viewport is resized. + * + * @ignore + */ + 'drupalViewportOffsetChange.TableHeader': tableHeaderOffsetChangeHandler + }); + + /** + * Constructor for the tableHeader object. Provides sticky table headers. + * + * TableHeader will make the current table header stick to the top of the page + * if the table is very long. + * + * @constructor Drupal.TableHeader + * + * @param {HTMLElement} table + * DOM object for the table to add a sticky header to. + * + * @listens event:columnschange + */ + function TableHeader(table) { + var $table = $(table); + + /** + * @name Drupal.TableHeader#$originalTable + * + * @type {HTMLElement} + */ + this.$originalTable = $table; + + /** + * @type {jQuery} + */ + this.$originalHeader = $table.children('thead'); + + /** + * @type {jQuery} + */ + this.$originalHeaderCells = this.$originalHeader.find('> tr > th'); + + /** + * @type {null|bool} + */ + this.displayWeight = null; + this.$originalTable.addClass('sticky-table'); + this.tableHeight = $table[0].clientHeight; + this.tableOffset = this.$originalTable.offset(); + + // React to columns change to avoid making checks in the scroll callback. + this.$originalTable.on('columnschange', {tableHeader: this}, function (e, display) { + var tableHeader = e.data.tableHeader; + if (tableHeader.displayWeight === null || tableHeader.displayWeight !== display) { + tableHeader.recalculateSticky(); + } + tableHeader.displayWeight = display; + }); + + // Create and display sticky header. + this.createSticky(); + } + + /** + * Store the state of TableHeader. + */ + $.extend(TableHeader, /** @lends Drupal.TableHeader */{ + + /** + * This will store the state of all processed tables. + * + * @type {Array.<Drupal.TableHeader>} + */ + tables: [] + }); + + /** + * Extend TableHeader prototype. + */ + $.extend(TableHeader.prototype, /** @lends Drupal.TableHeader# */{ + + /** + * Minimum height in pixels for the table to have a sticky header. + * + * @type {number} + */ + minHeight: 100, + + /** + * Absolute position of the table on the page. + * + * @type {?Drupal~displaceOffset} + */ + tableOffset: null, + + /** + * Absolute position of the table on the page. + * + * @type {?number} + */ + tableHeight: null, + + /** + * Boolean storing the sticky header visibility state. + * + * @type {bool} + */ + stickyVisible: false, + + /** + * Create the duplicate header. + */ + createSticky: function () { + // Clone the table header so it inherits original jQuery properties. + var $stickyHeader = this.$originalHeader.clone(true); + // Hide the table to avoid a flash of the header clone upon page load. + this.$stickyTable = $('<table class="sticky-header"/>') + .css({ + visibility: 'hidden', + position: 'fixed', + top: '0px' + }) + .append($stickyHeader) + .insertBefore(this.$originalTable); + + this.$stickyHeaderCells = $stickyHeader.find('> tr > th'); + + // Initialize all computations. + this.recalculateSticky(); + }, + + /** + * Set absolute position of sticky. + * + * @param {number} offsetTop + * The top offset for the sticky header. + * @param {number} offsetLeft + * The left offset for the sticky header. + * + * @return {jQuery} + * The sticky table as a jQuery collection. + */ + stickyPosition: function (offsetTop, offsetLeft) { + var css = {}; + if (typeof offsetTop === 'number') { + css.top = offsetTop + 'px'; + } + if (typeof offsetLeft === 'number') { + css.left = (this.tableOffset.left - offsetLeft) + 'px'; + } + return this.$stickyTable.css(css); + }, + + /** + * Returns true if sticky is currently visible. + * + * @return {bool} + * The visibility status. + */ + checkStickyVisible: function () { + var scrollTop = scrollValue('scrollTop'); + var tableTop = this.tableOffset.top - displace.offsets.top; + var tableBottom = tableTop + this.tableHeight; + var visible = false; + + if (tableTop < scrollTop && scrollTop < (tableBottom - this.minHeight)) { + visible = true; + } + + this.stickyVisible = visible; + return visible; + }, + + /** + * Check if sticky header should be displayed. + * + * This function is throttled to once every 250ms to avoid unnecessary + * calls. + * + * @param {jQuery.Event} e + * The scroll event. + */ + onScroll: function (e) { + this.checkStickyVisible(); + // Track horizontal positioning relative to the viewport. + this.stickyPosition(null, scrollValue('scrollLeft')); + this.$stickyTable.css('visibility', this.stickyVisible ? 'visible' : 'hidden'); + }, + + /** + * Event handler: recalculates position of the sticky table header. + * + * @param {jQuery.Event} event + * Event being triggered. + */ + recalculateSticky: function (event) { + // Update table size. + this.tableHeight = this.$originalTable[0].clientHeight; + + // Update offset top. + displace.offsets.top = displace.calculateOffset('top'); + this.tableOffset = this.$originalTable.offset(); + this.stickyPosition(displace.offsets.top, scrollValue('scrollLeft')); + + // Update columns width. + var $that = null; + var $stickyCell = null; + var display = null; + // Resize header and its cell widths. + // Only apply width to visible table cells. This prevents the header from + // displaying incorrectly when the sticky header is no longer visible. + var il = this.$originalHeaderCells.length; + for (var i = 0; i < il; i++) { + $that = $(this.$originalHeaderCells[i]); + $stickyCell = this.$stickyHeaderCells.eq($that.index()); + display = $that.css('display'); + if (display !== 'none') { + $stickyCell.css({width: $that.css('width'), display: display}); + } + else { + $stickyCell.css('display', 'none'); + } + } + this.$stickyTable.css('width', this.$originalTable.outerWidth()); + } + }); + + // Expose constructor in the public space. + Drupal.TableHeader = TableHeader; + +}(jQuery, Drupal, window.parent.Drupal.displace)); diff --git a/core/misc/tableheader.js b/core/misc/tableheader.js index 8fc1b048963c..ccbd89f833c2 100644 --- a/core/misc/tableheader.js +++ b/core/misc/tableheader.js @@ -1,23 +1,18 @@ /** - * @file - * Sticky table headers. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/tableheader.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, displace) { 'use strict'; - /** - * Attaches sticky table headers. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the sticky table header behavior. - */ Drupal.behaviors.tableHeader = { - attach: function (context) { - $(window).one('scroll.TableHeaderInit', {context: context}, tableHeaderInitHandler); + attach: function attach(context) { + $(window).one('scroll.TableHeaderInit', { context: context }, tableHeaderInitHandler); } }; @@ -25,7 +20,6 @@ return document.documentElement[position] || document.body[position]; } - // Select and initialize sticky table headers. function tableHeaderInitHandler(e) { var $tables = $(e.data.context).find('table.sticky-enabled').once('tableheader'); var il = $tables.length; @@ -35,7 +29,6 @@ forTables('onScroll'); } - // Helper method to loop through tables and execute a method. function forTables(method, arg) { var tables = TableHeader.tables; var il = tables.length; @@ -56,85 +49,33 @@ forTables('stickyPosition', offsets.top); } - // Bind event that need to change all tables. $(window).on({ - - /** - * When resizing table width can change, recalculate everything. - * - * @ignore - */ 'resize.TableHeader': tableHeaderResizeHandler, - /** - * Bind only one event to take care of calling all scroll callbacks. - * - * @ignore - */ 'scroll.TableHeader': tableHeaderOnScrollHandler }); - // Bind to custom Drupal events. - $(document).on({ - /** - * Recalculate columns width when window is resized and when show/hide - * weight is triggered. - * - * @ignore - */ + $(document).on({ 'columnschange.TableHeader': tableHeaderResizeHandler, - /** - * Recalculate TableHeader.topOffset when viewport is resized. - * - * @ignore - */ 'drupalViewportOffsetChange.TableHeader': tableHeaderOffsetChangeHandler }); - /** - * Constructor for the tableHeader object. Provides sticky table headers. - * - * TableHeader will make the current table header stick to the top of the page - * if the table is very long. - * - * @constructor Drupal.TableHeader - * - * @param {HTMLElement} table - * DOM object for the table to add a sticky header to. - * - * @listens event:columnschange - */ function TableHeader(table) { var $table = $(table); - /** - * @name Drupal.TableHeader#$originalTable - * - * @type {HTMLElement} - */ this.$originalTable = $table; - /** - * @type {jQuery} - */ this.$originalHeader = $table.children('thead'); - /** - * @type {jQuery} - */ this.$originalHeaderCells = this.$originalHeader.find('> tr > th'); - /** - * @type {null|bool} - */ this.displayWeight = null; this.$originalTable.addClass('sticky-table'); this.tableHeight = $table[0].clientHeight; this.tableOffset = this.$originalTable.offset(); - // React to columns change to avoid making checks in the scroll callback. - this.$originalTable.on('columnschange', {tableHeader: this}, function (e, display) { + this.$originalTable.on('columnschange', { tableHeader: this }, function (e, display) { var tableHeader = e.data.tableHeader; if (tableHeader.displayWeight === null || tableHeader.displayWeight !== display) { tableHeader.recalculateSticky(); @@ -142,113 +83,54 @@ tableHeader.displayWeight = display; }); - // Create and display sticky header. this.createSticky(); } - /** - * Store the state of TableHeader. - */ - $.extend(TableHeader, /** @lends Drupal.TableHeader */{ - - /** - * This will store the state of all processed tables. - * - * @type {Array.<Drupal.TableHeader>} - */ + $.extend(TableHeader, { tables: [] }); - /** - * Extend TableHeader prototype. - */ - $.extend(TableHeader.prototype, /** @lends Drupal.TableHeader# */{ - - /** - * Minimum height in pixels for the table to have a sticky header. - * - * @type {number} - */ + $.extend(TableHeader.prototype, { minHeight: 100, - /** - * Absolute position of the table on the page. - * - * @type {?Drupal~displaceOffset} - */ tableOffset: null, - /** - * Absolute position of the table on the page. - * - * @type {?number} - */ tableHeight: null, - /** - * Boolean storing the sticky header visibility state. - * - * @type {bool} - */ stickyVisible: false, - /** - * Create the duplicate header. - */ - createSticky: function () { - // Clone the table header so it inherits original jQuery properties. + createSticky: function createSticky() { var $stickyHeader = this.$originalHeader.clone(true); - // Hide the table to avoid a flash of the header clone upon page load. - this.$stickyTable = $('<table class="sticky-header"/>') - .css({ - visibility: 'hidden', - position: 'fixed', - top: '0px' - }) - .append($stickyHeader) - .insertBefore(this.$originalTable); + + this.$stickyTable = $('<table class="sticky-header"/>').css({ + visibility: 'hidden', + position: 'fixed', + top: '0px' + }).append($stickyHeader).insertBefore(this.$originalTable); this.$stickyHeaderCells = $stickyHeader.find('> tr > th'); - // Initialize all computations. this.recalculateSticky(); }, - /** - * Set absolute position of sticky. - * - * @param {number} offsetTop - * The top offset for the sticky header. - * @param {number} offsetLeft - * The left offset for the sticky header. - * - * @return {jQuery} - * The sticky table as a jQuery collection. - */ - stickyPosition: function (offsetTop, offsetLeft) { + stickyPosition: function stickyPosition(offsetTop, offsetLeft) { var css = {}; if (typeof offsetTop === 'number') { css.top = offsetTop + 'px'; } if (typeof offsetLeft === 'number') { - css.left = (this.tableOffset.left - offsetLeft) + 'px'; + css.left = this.tableOffset.left - offsetLeft + 'px'; } return this.$stickyTable.css(css); }, - /** - * Returns true if sticky is currently visible. - * - * @return {bool} - * The visibility status. - */ - checkStickyVisible: function () { + checkStickyVisible: function checkStickyVisible() { var scrollTop = scrollValue('scrollTop'); var tableTop = this.tableOffset.top - displace.offsets.top; var tableBottom = tableTop + this.tableHeight; var visible = false; - if (tableTop < scrollTop && scrollTop < (tableBottom - this.minHeight)) { + if (tableTop < scrollTop && scrollTop < tableBottom - this.minHeight) { visible = true; } @@ -256,53 +138,32 @@ return visible; }, - /** - * Check if sticky header should be displayed. - * - * This function is throttled to once every 250ms to avoid unnecessary - * calls. - * - * @param {jQuery.Event} e - * The scroll event. - */ - onScroll: function (e) { + onScroll: function onScroll(e) { this.checkStickyVisible(); - // Track horizontal positioning relative to the viewport. + this.stickyPosition(null, scrollValue('scrollLeft')); this.$stickyTable.css('visibility', this.stickyVisible ? 'visible' : 'hidden'); }, - /** - * Event handler: recalculates position of the sticky table header. - * - * @param {jQuery.Event} event - * Event being triggered. - */ - recalculateSticky: function (event) { - // Update table size. + recalculateSticky: function recalculateSticky(event) { this.tableHeight = this.$originalTable[0].clientHeight; - // Update offset top. displace.offsets.top = displace.calculateOffset('top'); this.tableOffset = this.$originalTable.offset(); this.stickyPosition(displace.offsets.top, scrollValue('scrollLeft')); - // Update columns width. var $that = null; var $stickyCell = null; var display = null; - // Resize header and its cell widths. - // Only apply width to visible table cells. This prevents the header from - // displaying incorrectly when the sticky header is no longer visible. + var il = this.$originalHeaderCells.length; for (var i = 0; i < il; i++) { $that = $(this.$originalHeaderCells[i]); $stickyCell = this.$stickyHeaderCells.eq($that.index()); display = $that.css('display'); if (display !== 'none') { - $stickyCell.css({width: $that.css('width'), display: display}); - } - else { + $stickyCell.css({ width: $that.css('width'), display: display }); + } else { $stickyCell.css('display', 'none'); } } @@ -310,7 +171,5 @@ } }); - // Expose constructor in the public space. Drupal.TableHeader = TableHeader; - -}(jQuery, Drupal, window.parent.Drupal.displace)); +})(jQuery, Drupal, window.parent.Drupal.displace); \ No newline at end of file diff --git a/core/misc/tableresponsive.es6.js b/core/misc/tableresponsive.es6.js new file mode 100644 index 000000000000..0127ec88735b --- /dev/null +++ b/core/misc/tableresponsive.es6.js @@ -0,0 +1,174 @@ +/** + * @file + * Responsive table functionality. + */ + +(function ($, Drupal, window) { + + 'use strict'; + + /** + * Attach the tableResponsive function to {@link Drupal.behaviors}. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches tableResponsive functionality. + */ + Drupal.behaviors.tableResponsive = { + attach: function (context, settings) { + var $tables = $(context).find('table.responsive-enabled').once('tableresponsive'); + if ($tables.length) { + var il = $tables.length; + for (var i = 0; i < il; i++) { + TableResponsive.tables.push(new TableResponsive($tables[i])); + } + } + } + }; + + /** + * The TableResponsive object optimizes table presentation for screen size. + * + * A responsive table hides columns at small screen sizes, leaving the most + * important columns visible to the end user. Users should not be prevented + * from accessing all columns, however. This class adds a toggle to a table + * with hidden columns that exposes the columns. Exposing the columns will + * likely break layouts, but it provides the user with a means to access + * data, which is a guiding principle of responsive design. + * + * @constructor Drupal.TableResponsive + * + * @param {HTMLElement} table + * The table element to initialize the responsive table on. + */ + function TableResponsive(table) { + this.table = table; + this.$table = $(table); + this.showText = Drupal.t('Show all columns'); + this.hideText = Drupal.t('Hide lower priority columns'); + // Store a reference to the header elements of the table so that the DOM is + // traversed only once to find them. + this.$headers = this.$table.find('th'); + // Add a link before the table for users to show or hide weight columns. + this.$link = $('<button type="button" class="link tableresponsive-toggle"></button>') + .attr('title', Drupal.t('Show table cells that were hidden to make the table fit within a small screen.')) + .on('click', $.proxy(this, 'eventhandlerToggleColumns')); + + this.$table.before($('<div class="tableresponsive-toggle-columns"></div>').append(this.$link)); + + // Attach a resize handler to the window. + $(window) + .on('resize.tableresponsive', $.proxy(this, 'eventhandlerEvaluateColumnVisibility')) + .trigger('resize.tableresponsive'); + } + + /** + * Extend the TableResponsive function with a list of managed tables. + */ + $.extend(TableResponsive, /** @lends Drupal.TableResponsive */{ + + /** + * Store all created instances. + * + * @type {Array.<Drupal.TableResponsive>} + */ + tables: [] + }); + + /** + * Associates an action link with the table that will show hidden columns. + * + * Columns are assumed to be hidden if their header has the class priority-low + * or priority-medium. + */ + $.extend(TableResponsive.prototype, /** @lends Drupal.TableResponsive# */{ + + /** + * @param {jQuery.Event} e + * The event triggered. + */ + eventhandlerEvaluateColumnVisibility: function (e) { + var pegged = parseInt(this.$link.data('pegged'), 10); + var hiddenLength = this.$headers.filter('.priority-medium:hidden, .priority-low:hidden').length; + // If the table has hidden columns, associate an action link with the + // table to show the columns. + if (hiddenLength > 0) { + this.$link.show().text(this.showText); + } + // When the toggle is pegged, its presence is maintained because the user + // has interacted with it. This is necessary to keep the link visible if + // the user adjusts screen size and changes the visibility of columns. + if (!pegged && hiddenLength === 0) { + this.$link.hide().text(this.hideText); + } + }, + + /** + * Toggle the visibility of columns based on their priority. + * + * Columns are classed with either 'priority-low' or 'priority-medium'. + * + * @param {jQuery.Event} e + * The event triggered. + */ + eventhandlerToggleColumns: function (e) { + e.preventDefault(); + var self = this; + var $hiddenHeaders = this.$headers.filter('.priority-medium:hidden, .priority-low:hidden'); + this.$revealedCells = this.$revealedCells || $(); + // Reveal hidden columns. + if ($hiddenHeaders.length > 0) { + $hiddenHeaders.each(function (index, element) { + var $header = $(this); + var position = $header.prevAll('th').length; + self.$table.find('tbody tr').each(function () { + var $cells = $(this).find('td').eq(position); + $cells.show(); + // Keep track of the revealed cells, so they can be hidden later. + self.$revealedCells = $().add(self.$revealedCells).add($cells); + }); + $header.show(); + // Keep track of the revealed headers, so they can be hidden later. + self.$revealedCells = $().add(self.$revealedCells).add($header); + }); + this.$link.text(this.hideText).data('pegged', 1); + } + // Hide revealed columns. + else { + this.$revealedCells.hide(); + // Strip the 'display:none' declaration from the style attributes of + // the table cells that .hide() added. + this.$revealedCells.each(function (index, element) { + var $cell = $(this); + var properties = $cell.attr('style').split(';'); + var newProps = []; + // The hide method adds display none to the element. The element + // should be returned to the same state it was in before the columns + // were revealed, so it is necessary to remove the display none value + // from the style attribute. + var match = /^display\s*\:\s*none$/; + for (var i = 0; i < properties.length; i++) { + var prop = properties[i]; + prop.trim(); + // Find the display:none property and remove it. + var isDisplayNone = match.exec(prop); + if (isDisplayNone) { + continue; + } + newProps.push(prop); + } + // Return the rest of the style attribute values to the element. + $cell.attr('style', newProps.join(';')); + }); + this.$link.text(this.showText).data('pegged', 0); + // Refresh the toggle link. + $(window).trigger('resize.tableresponsive'); + } + } + }); + + // Make the TableResponsive object available in the Drupal namespace. + Drupal.TableResponsive = TableResponsive; + +})(jQuery, Drupal, window); diff --git a/core/misc/tableresponsive.js b/core/misc/tableresponsive.js index 0127ec88735b..c42530d8c97c 100644 --- a/core/misc/tableresponsive.js +++ b/core/misc/tableresponsive.js @@ -1,22 +1,17 @@ /** - * @file - * Responsive table functionality. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/tableresponsive.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, window) { 'use strict'; - /** - * Attach the tableResponsive function to {@link Drupal.behaviors}. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches tableResponsive functionality. - */ Drupal.behaviors.tableResponsive = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $tables = $(context).find('table.responsive-enabled').once('tableresponsive'); if ($tables.length) { var il = $tables.length; @@ -27,97 +22,45 @@ } }; - /** - * The TableResponsive object optimizes table presentation for screen size. - * - * A responsive table hides columns at small screen sizes, leaving the most - * important columns visible to the end user. Users should not be prevented - * from accessing all columns, however. This class adds a toggle to a table - * with hidden columns that exposes the columns. Exposing the columns will - * likely break layouts, but it provides the user with a means to access - * data, which is a guiding principle of responsive design. - * - * @constructor Drupal.TableResponsive - * - * @param {HTMLElement} table - * The table element to initialize the responsive table on. - */ function TableResponsive(table) { this.table = table; this.$table = $(table); this.showText = Drupal.t('Show all columns'); this.hideText = Drupal.t('Hide lower priority columns'); - // Store a reference to the header elements of the table so that the DOM is - // traversed only once to find them. + this.$headers = this.$table.find('th'); - // Add a link before the table for users to show or hide weight columns. - this.$link = $('<button type="button" class="link tableresponsive-toggle"></button>') - .attr('title', Drupal.t('Show table cells that were hidden to make the table fit within a small screen.')) - .on('click', $.proxy(this, 'eventhandlerToggleColumns')); + + this.$link = $('<button type="button" class="link tableresponsive-toggle"></button>').attr('title', Drupal.t('Show table cells that were hidden to make the table fit within a small screen.')).on('click', $.proxy(this, 'eventhandlerToggleColumns')); this.$table.before($('<div class="tableresponsive-toggle-columns"></div>').append(this.$link)); - // Attach a resize handler to the window. - $(window) - .on('resize.tableresponsive', $.proxy(this, 'eventhandlerEvaluateColumnVisibility')) - .trigger('resize.tableresponsive'); + $(window).on('resize.tableresponsive', $.proxy(this, 'eventhandlerEvaluateColumnVisibility')).trigger('resize.tableresponsive'); } - /** - * Extend the TableResponsive function with a list of managed tables. - */ - $.extend(TableResponsive, /** @lends Drupal.TableResponsive */{ - - /** - * Store all created instances. - * - * @type {Array.<Drupal.TableResponsive>} - */ + $.extend(TableResponsive, { tables: [] }); - /** - * Associates an action link with the table that will show hidden columns. - * - * Columns are assumed to be hidden if their header has the class priority-low - * or priority-medium. - */ - $.extend(TableResponsive.prototype, /** @lends Drupal.TableResponsive# */{ - - /** - * @param {jQuery.Event} e - * The event triggered. - */ - eventhandlerEvaluateColumnVisibility: function (e) { + $.extend(TableResponsive.prototype, { + eventhandlerEvaluateColumnVisibility: function eventhandlerEvaluateColumnVisibility(e) { var pegged = parseInt(this.$link.data('pegged'), 10); var hiddenLength = this.$headers.filter('.priority-medium:hidden, .priority-low:hidden').length; - // If the table has hidden columns, associate an action link with the - // table to show the columns. + if (hiddenLength > 0) { this.$link.show().text(this.showText); } - // When the toggle is pegged, its presence is maintained because the user - // has interacted with it. This is necessary to keep the link visible if - // the user adjusts screen size and changes the visibility of columns. + if (!pegged && hiddenLength === 0) { this.$link.hide().text(this.hideText); } }, - /** - * Toggle the visibility of columns based on their priority. - * - * Columns are classed with either 'priority-low' or 'priority-medium'. - * - * @param {jQuery.Event} e - * The event triggered. - */ - eventhandlerToggleColumns: function (e) { + eventhandlerToggleColumns: function eventhandlerToggleColumns(e) { e.preventDefault(); var self = this; var $hiddenHeaders = this.$headers.filter('.priority-medium:hidden, .priority-low:hidden'); this.$revealedCells = this.$revealedCells || $(); - // Reveal hidden columns. + if ($hiddenHeaders.length > 0) { $hiddenHeaders.each(function (index, element) { var $header = $(this); @@ -125,50 +68,42 @@ self.$table.find('tbody tr').each(function () { var $cells = $(this).find('td').eq(position); $cells.show(); - // Keep track of the revealed cells, so they can be hidden later. + self.$revealedCells = $().add(self.$revealedCells).add($cells); }); $header.show(); - // Keep track of the revealed headers, so they can be hidden later. + self.$revealedCells = $().add(self.$revealedCells).add($header); }); this.$link.text(this.hideText).data('pegged', 1); - } - // Hide revealed columns. - else { - this.$revealedCells.hide(); - // Strip the 'display:none' declaration from the style attributes of - // the table cells that .hide() added. - this.$revealedCells.each(function (index, element) { - var $cell = $(this); - var properties = $cell.attr('style').split(';'); - var newProps = []; - // The hide method adds display none to the element. The element - // should be returned to the same state it was in before the columns - // were revealed, so it is necessary to remove the display none value - // from the style attribute. - var match = /^display\s*\:\s*none$/; - for (var i = 0; i < properties.length; i++) { - var prop = properties[i]; - prop.trim(); - // Find the display:none property and remove it. - var isDisplayNone = match.exec(prop); - if (isDisplayNone) { - continue; + } else { + this.$revealedCells.hide(); + + this.$revealedCells.each(function (index, element) { + var $cell = $(this); + var properties = $cell.attr('style').split(';'); + var newProps = []; + + var match = /^display\s*\:\s*none$/; + for (var i = 0; i < properties.length; i++) { + var prop = properties[i]; + prop.trim(); + + var isDisplayNone = match.exec(prop); + if (isDisplayNone) { + continue; + } + newProps.push(prop); } - newProps.push(prop); - } - // Return the rest of the style attribute values to the element. - $cell.attr('style', newProps.join(';')); - }); - this.$link.text(this.showText).data('pegged', 0); - // Refresh the toggle link. - $(window).trigger('resize.tableresponsive'); - } + + $cell.attr('style', newProps.join(';')); + }); + this.$link.text(this.showText).data('pegged', 0); + + $(window).trigger('resize.tableresponsive'); + } } }); - // Make the TableResponsive object available in the Drupal namespace. Drupal.TableResponsive = TableResponsive; - -})(jQuery, Drupal, window); +})(jQuery, Drupal, window); \ No newline at end of file diff --git a/core/misc/tableselect.es6.js b/core/misc/tableselect.es6.js new file mode 100644 index 000000000000..243e0001107d --- /dev/null +++ b/core/misc/tableselect.es6.js @@ -0,0 +1,159 @@ +/** + * @file + * Table select functionality. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Initialize tableSelects. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches tableSelect functionality. + */ + Drupal.behaviors.tableSelect = { + attach: function (context, settings) { + // Select the inner-most table in case of nested tables. + $(context).find('th.select-all').closest('table').once('table-select').each(Drupal.tableSelect); + } + }; + + /** + * Callback used in {@link Drupal.behaviors.tableSelect}. + */ + Drupal.tableSelect = function () { + // Do not add a "Select all" checkbox if there are no rows with checkboxes + // in the table. + if ($(this).find('td input[type="checkbox"]').length === 0) { + return; + } + + // Keep track of the table, which checkbox is checked and alias the + // settings. + var table = this; + var checkboxes; + var lastChecked; + var $table = $(table); + var strings = { + selectAll: Drupal.t('Select all rows in this table'), + selectNone: Drupal.t('Deselect all rows in this table') + }; + var updateSelectAll = function (state) { + // Update table's select-all checkbox (and sticky header's if available). + $table.prev('table.sticky-header').addBack().find('th.select-all input[type="checkbox"]').each(function () { + var $checkbox = $(this); + var stateChanged = $checkbox.prop('checked') !== state; + + $checkbox.attr('title', state ? strings.selectNone : strings.selectAll); + + /** + * @checkbox {HTMLElement} + */ + if (stateChanged) { + $checkbox.prop('checked', state).trigger('change'); + } + }); + }; + + // Find all <th> with class select-all, and insert the check all checkbox. + $table.find('th.select-all').prepend($('<input type="checkbox" class="form-checkbox" />').attr('title', strings.selectAll)).on('click', function (event) { + if ($(event.target).is('input[type="checkbox"]')) { + // Loop through all checkboxes and set their state to the select all + // checkbox' state. + checkboxes.each(function () { + var $checkbox = $(this); + var stateChanged = $checkbox.prop('checked') !== event.target.checked; + + /** + * @checkbox {HTMLElement} + */ + if (stateChanged) { + $checkbox.prop('checked', event.target.checked).trigger('change'); + } + // Either add or remove the selected class based on the state of the + // check all checkbox. + + /** + * @checkbox {HTMLElement} + */ + $checkbox.closest('tr').toggleClass('selected', this.checked); + }); + // Update the title and the state of the check all box. + updateSelectAll(event.target.checked); + } + }); + + // For each of the checkboxes within the table that are not disabled. + checkboxes = $table.find('td input[type="checkbox"]:enabled').on('click', function (e) { + // Either add or remove the selected class based on the state of the + // check all checkbox. + + /** + * @this {HTMLElement} + */ + $(this).closest('tr').toggleClass('selected', this.checked); + + // If this is a shift click, we need to highlight everything in the + // range. Also make sure that we are actually checking checkboxes + // over a range and that a checkbox has been checked or unchecked before. + if (e.shiftKey && lastChecked && lastChecked !== e.target) { + // We use the checkbox's parent <tr> to do our range searching. + Drupal.tableSelectRange($(e.target).closest('tr')[0], $(lastChecked).closest('tr')[0], e.target.checked); + } + + // If all checkboxes are checked, make sure the select-all one is checked + // too, otherwise keep unchecked. + updateSelectAll((checkboxes.length === checkboxes.filter(':checked').length)); + + // Keep track of the last checked checkbox. + lastChecked = e.target; + }); + + // If all checkboxes are checked on page load, make sure the select-all one + // is checked too, otherwise keep unchecked. + updateSelectAll((checkboxes.length === checkboxes.filter(':checked').length)); + }; + + /** + * @param {HTMLElement} from + * The HTML element representing the "from" part of the range. + * @param {HTMLElement} to + * The HTML element representing the "to" part of the range. + * @param {bool} state + * The state to set on the range. + */ + Drupal.tableSelectRange = function (from, to, state) { + // We determine the looping mode based on the order of from and to. + var mode = from.rowIndex > to.rowIndex ? 'previousSibling' : 'nextSibling'; + + // Traverse through the sibling nodes. + for (var i = from[mode]; i; i = i[mode]) { + var $i; + // Make sure that we're only dealing with elements. + if (i.nodeType !== 1) { + continue; + } + $i = $(i); + // Either add or remove the selected class based on the state of the + // target checkbox. + $i.toggleClass('selected', state); + $i.find('input[type="checkbox"]').prop('checked', state); + + if (to.nodeType) { + // If we are at the end of the range, stop. + if (i === to) { + break; + } + } + // A faster alternative to doing $(i).filter(to).length. + else if ($.filter(to, [i]).r.length) { + break; + } + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/tableselect.js b/core/misc/tableselect.js index 243e0001107d..a111fbeacebb 100644 --- a/core/misc/tableselect.js +++ b/core/misc/tableselect.js @@ -1,39 +1,26 @@ /** - * @file - * Table select functionality. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/tableselect.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Initialize tableSelects. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches tableSelect functionality. - */ Drupal.behaviors.tableSelect = { - attach: function (context, settings) { - // Select the inner-most table in case of nested tables. + attach: function attach(context, settings) { $(context).find('th.select-all').closest('table').once('table-select').each(Drupal.tableSelect); } }; - /** - * Callback used in {@link Drupal.behaviors.tableSelect}. - */ Drupal.tableSelect = function () { - // Do not add a "Select all" checkbox if there are no rows with checkboxes - // in the table. if ($(this).find('td input[type="checkbox"]').length === 0) { return; } - // Keep track of the table, which checkbox is checked and alias the - // settings. var table = this; var checkboxes; var lastChecked; @@ -42,118 +29,72 @@ selectAll: Drupal.t('Select all rows in this table'), selectNone: Drupal.t('Deselect all rows in this table') }; - var updateSelectAll = function (state) { - // Update table's select-all checkbox (and sticky header's if available). + var updateSelectAll = function updateSelectAll(state) { $table.prev('table.sticky-header').addBack().find('th.select-all input[type="checkbox"]').each(function () { var $checkbox = $(this); var stateChanged = $checkbox.prop('checked') !== state; $checkbox.attr('title', state ? strings.selectNone : strings.selectAll); - /** - * @checkbox {HTMLElement} - */ if (stateChanged) { $checkbox.prop('checked', state).trigger('change'); } }); }; - // Find all <th> with class select-all, and insert the check all checkbox. $table.find('th.select-all').prepend($('<input type="checkbox" class="form-checkbox" />').attr('title', strings.selectAll)).on('click', function (event) { if ($(event.target).is('input[type="checkbox"]')) { - // Loop through all checkboxes and set their state to the select all - // checkbox' state. checkboxes.each(function () { var $checkbox = $(this); var stateChanged = $checkbox.prop('checked') !== event.target.checked; - /** - * @checkbox {HTMLElement} - */ if (stateChanged) { $checkbox.prop('checked', event.target.checked).trigger('change'); } - // Either add or remove the selected class based on the state of the - // check all checkbox. - /** - * @checkbox {HTMLElement} - */ $checkbox.closest('tr').toggleClass('selected', this.checked); }); - // Update the title and the state of the check all box. + updateSelectAll(event.target.checked); } }); - // For each of the checkboxes within the table that are not disabled. checkboxes = $table.find('td input[type="checkbox"]:enabled').on('click', function (e) { - // Either add or remove the selected class based on the state of the - // check all checkbox. - - /** - * @this {HTMLElement} - */ $(this).closest('tr').toggleClass('selected', this.checked); - // If this is a shift click, we need to highlight everything in the - // range. Also make sure that we are actually checking checkboxes - // over a range and that a checkbox has been checked or unchecked before. if (e.shiftKey && lastChecked && lastChecked !== e.target) { - // We use the checkbox's parent <tr> to do our range searching. Drupal.tableSelectRange($(e.target).closest('tr')[0], $(lastChecked).closest('tr')[0], e.target.checked); } - // If all checkboxes are checked, make sure the select-all one is checked - // too, otherwise keep unchecked. - updateSelectAll((checkboxes.length === checkboxes.filter(':checked').length)); + updateSelectAll(checkboxes.length === checkboxes.filter(':checked').length); - // Keep track of the last checked checkbox. lastChecked = e.target; }); - // If all checkboxes are checked on page load, make sure the select-all one - // is checked too, otherwise keep unchecked. - updateSelectAll((checkboxes.length === checkboxes.filter(':checked').length)); + updateSelectAll(checkboxes.length === checkboxes.filter(':checked').length); }; - /** - * @param {HTMLElement} from - * The HTML element representing the "from" part of the range. - * @param {HTMLElement} to - * The HTML element representing the "to" part of the range. - * @param {bool} state - * The state to set on the range. - */ Drupal.tableSelectRange = function (from, to, state) { - // We determine the looping mode based on the order of from and to. var mode = from.rowIndex > to.rowIndex ? 'previousSibling' : 'nextSibling'; - // Traverse through the sibling nodes. for (var i = from[mode]; i; i = i[mode]) { var $i; - // Make sure that we're only dealing with elements. + if (i.nodeType !== 1) { continue; } $i = $(i); - // Either add or remove the selected class based on the state of the - // target checkbox. + $i.toggleClass('selected', state); $i.find('input[type="checkbox"]').prop('checked', state); if (to.nodeType) { - // If we are at the end of the range, stop. if (i === to) { break; } - } - // A faster alternative to doing $(i).filter(to).length. - else if ($.filter(to, [i]).r.length) { - break; - } + } else if ($.filter(to, [i]).r.length) { + break; + } } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/timezone.es6.js b/core/misc/timezone.es6.js new file mode 100644 index 000000000000..3c88d463dd80 --- /dev/null +++ b/core/misc/timezone.es6.js @@ -0,0 +1,76 @@ +/** + * @file + * Timezone detection. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Set the client's system time zone as default values of form fields. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.setTimezone = { + attach: function (context, settings) { + var $timezone = $(context).find('.timezone-detect').once('timezone'); + if ($timezone.length) { + var dateString = Date(); + // In some client environments, date strings include a time zone + // abbreviation, between 3 and 5 letters enclosed in parentheses, + // which can be interpreted by PHP. + var matches = dateString.match(/\(([A-Z]{3,5})\)/); + var abbreviation = matches ? matches[1] : 0; + + // For all other client environments, the abbreviation is set to "0" + // and the current offset from UTC and daylight saving time status are + // used to guess the time zone. + var dateNow = new Date(); + var offsetNow = dateNow.getTimezoneOffset() * -60; + + // Use January 1 and July 1 as test dates for determining daylight + // saving time status by comparing their offsets. + var dateJan = new Date(dateNow.getFullYear(), 0, 1, 12, 0, 0, 0); + var dateJul = new Date(dateNow.getFullYear(), 6, 1, 12, 0, 0, 0); + var offsetJan = dateJan.getTimezoneOffset() * -60; + var offsetJul = dateJul.getTimezoneOffset() * -60; + + var isDaylightSavingTime; + // If the offset from UTC is identical on January 1 and July 1, + // assume daylight saving time is not used in this time zone. + if (offsetJan === offsetJul) { + isDaylightSavingTime = ''; + } + // If the maximum annual offset is equivalent to the current offset, + // assume daylight saving time is in effect. + else if (Math.max(offsetJan, offsetJul) === offsetNow) { + isDaylightSavingTime = 1; + } + // Otherwise, assume daylight saving time is not in effect. + else { + isDaylightSavingTime = 0; + } + + // Submit request to the system/timezone callback and set the form + // field to the response time zone. The client date is passed to the + // callback for debugging purposes. Submit a synchronous request to + // avoid database errors associated with concurrent requests + // during install. + var path = 'system/timezone/' + abbreviation + '/' + offsetNow + '/' + isDaylightSavingTime; + $.ajax({ + async: false, + url: Drupal.url(path), + data: {date: dateString}, + dataType: 'json', + success: function (data) { + if (data) { + $timezone.val(data); + } + } + }); + } + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/timezone.js b/core/misc/timezone.js index 3c88d463dd80..cd47f08fbca8 100644 --- a/core/misc/timezone.js +++ b/core/misc/timezone.js @@ -1,69 +1,49 @@ /** - * @file - * Timezone detection. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/timezone.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Set the client's system time zone as default values of form fields. - * - * @type {Drupal~behavior} - */ Drupal.behaviors.setTimezone = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $timezone = $(context).find('.timezone-detect').once('timezone'); if ($timezone.length) { var dateString = Date(); - // In some client environments, date strings include a time zone - // abbreviation, between 3 and 5 letters enclosed in parentheses, - // which can be interpreted by PHP. + var matches = dateString.match(/\(([A-Z]{3,5})\)/); var abbreviation = matches ? matches[1] : 0; - // For all other client environments, the abbreviation is set to "0" - // and the current offset from UTC and daylight saving time status are - // used to guess the time zone. var dateNow = new Date(); var offsetNow = dateNow.getTimezoneOffset() * -60; - // Use January 1 and July 1 as test dates for determining daylight - // saving time status by comparing their offsets. var dateJan = new Date(dateNow.getFullYear(), 0, 1, 12, 0, 0, 0); var dateJul = new Date(dateNow.getFullYear(), 6, 1, 12, 0, 0, 0); var offsetJan = dateJan.getTimezoneOffset() * -60; var offsetJul = dateJul.getTimezoneOffset() * -60; var isDaylightSavingTime; - // If the offset from UTC is identical on January 1 and July 1, - // assume daylight saving time is not used in this time zone. + if (offsetJan === offsetJul) { isDaylightSavingTime = ''; - } - // If the maximum annual offset is equivalent to the current offset, - // assume daylight saving time is in effect. - else if (Math.max(offsetJan, offsetJul) === offsetNow) { - isDaylightSavingTime = 1; - } - // Otherwise, assume daylight saving time is not in effect. - else { - isDaylightSavingTime = 0; - } + } else if (Math.max(offsetJan, offsetJul) === offsetNow) { + isDaylightSavingTime = 1; + } else { + isDaylightSavingTime = 0; + } - // Submit request to the system/timezone callback and set the form - // field to the response time zone. The client date is passed to the - // callback for debugging purposes. Submit a synchronous request to - // avoid database errors associated with concurrent requests - // during install. var path = 'system/timezone/' + abbreviation + '/' + offsetNow + '/' + isDaylightSavingTime; $.ajax({ async: false, url: Drupal.url(path), - data: {date: dateString}, + data: { date: dateString }, dataType: 'json', - success: function (data) { + success: function success(data) { if (data) { $timezone.val(data); } @@ -72,5 +52,4 @@ } } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/misc/vertical-tabs.es6.js b/core/misc/vertical-tabs.es6.js new file mode 100644 index 000000000000..c7ad2fd2b44b --- /dev/null +++ b/core/misc/vertical-tabs.es6.js @@ -0,0 +1,252 @@ +/** + * @file + * Define vertical tabs functionality. + */ + +/** + * Triggers when form values inside a vertical tab changes. + * + * This is used to update the summary in vertical tabs in order to know what + * are the important fields' values. + * + * @event summaryUpdated + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * This script transforms a set of details into a stack of vertical tabs. + * + * Each tab may have a summary which can be updated by another + * script. For that to work, each details element has an associated + * 'verticalTabCallback' (with jQuery.data() attached to the details), + * which is called every time the user performs an update to a form + * element inside the tab pane. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behaviors for vertical tabs. + */ + Drupal.behaviors.verticalTabs = { + attach: function (context) { + var width = drupalSettings.widthBreakpoint || 640; + var mq = '(max-width: ' + width + 'px)'; + + if (window.matchMedia(mq).matches) { + return; + } + + $(context).find('[data-vertical-tabs-panes]').once('vertical-tabs').each(function () { + var $this = $(this).addClass('vertical-tabs__panes'); + var focusID = $this.find(':hidden.vertical-tabs__active-tab').val(); + var tab_focus; + + // Check if there are some details that can be converted to + // vertical-tabs. + var $details = $this.find('> details'); + if ($details.length === 0) { + return; + } + + // Create the tab column. + var tab_list = $('<ul class="vertical-tabs__menu"></ul>'); + $this.wrap('<div class="vertical-tabs clearfix"></div>').before(tab_list); + + // Transform each details into a tab. + $details.each(function () { + var $that = $(this); + var vertical_tab = new Drupal.verticalTab({ + title: $that.find('> summary').text(), + details: $that + }); + tab_list.append(vertical_tab.item); + $that + .removeClass('collapsed') + // prop() can't be used on browsers not supporting details element, + // the style won't apply to them if prop() is used. + .attr('open', true) + .addClass('vertical-tabs__pane') + .data('verticalTab', vertical_tab); + if (this.id === focusID) { + tab_focus = $that; + } + }); + + $(tab_list).find('> li').eq(0).addClass('first'); + $(tab_list).find('> li').eq(-1).addClass('last'); + + if (!tab_focus) { + // If the current URL has a fragment and one of the tabs contains an + // element that matches the URL fragment, activate that tab. + var $locationHash = $this.find(window.location.hash); + if (window.location.hash && $locationHash.length) { + tab_focus = $locationHash.closest('.vertical-tabs__pane'); + } + else { + tab_focus = $this.find('> .vertical-tabs__pane').eq(0); + } + } + if (tab_focus.length) { + tab_focus.data('verticalTab').focus(); + } + }); + } + }; + + /** + * The vertical tab object represents a single tab within a tab group. + * + * @constructor + * + * @param {object} settings + * Settings object. + * @param {string} settings.title + * The name of the tab. + * @param {jQuery} settings.details + * The jQuery object of the details element that is the tab pane. + * + * @fires event:summaryUpdated + * + * @listens event:summaryUpdated + */ + Drupal.verticalTab = function (settings) { + var self = this; + $.extend(this, settings, Drupal.theme('verticalTab', settings)); + + this.link.attr('href', '#' + settings.details.attr('id')); + + this.link.on('click', function (e) { + e.preventDefault(); + self.focus(); + }); + + // Keyboard events added: + // Pressing the Enter key will open the tab pane. + this.link.on('keydown', function (event) { + if (event.keyCode === 13) { + event.preventDefault(); + self.focus(); + // Set focus on the first input field of the visible details/tab pane. + $('.vertical-tabs__pane :input:visible:enabled').eq(0).trigger('focus'); + } + }); + + this.details + .on('summaryUpdated', function () { + self.updateSummary(); + }) + .trigger('summaryUpdated'); + }; + + Drupal.verticalTab.prototype = { + + /** + * Displays the tab's content pane. + */ + focus: function () { + this.details + .siblings('.vertical-tabs__pane') + .each(function () { + var tab = $(this).data('verticalTab'); + tab.details.hide(); + tab.item.removeClass('is-selected'); + }) + .end() + .show() + .siblings(':hidden.vertical-tabs__active-tab') + .val(this.details.attr('id')); + this.item.addClass('is-selected'); + // Mark the active tab for screen readers. + $('#active-vertical-tab').remove(); + this.link.append('<span id="active-vertical-tab" class="visually-hidden">' + Drupal.t('(active tab)') + '</span>'); + }, + + /** + * Updates the tab's summary. + */ + updateSummary: function () { + this.summary.html(this.details.drupalGetSummary()); + }, + + /** + * Shows a vertical tab pane. + * + * @return {Drupal.verticalTab} + * The verticalTab instance. + */ + tabShow: function () { + // Display the tab. + this.item.show(); + // Show the vertical tabs. + this.item.closest('.js-form-type-vertical-tabs').show(); + // Update .first marker for items. We need recurse from parent to retain + // the actual DOM element order as jQuery implements sortOrder, but not + // as public method. + this.item.parent().children('.vertical-tabs__menu-item').removeClass('first') + .filter(':visible').eq(0).addClass('first'); + // Display the details element. + this.details.removeClass('vertical-tab--hidden').show(); + // Focus this tab. + this.focus(); + return this; + }, + + /** + * Hides a vertical tab pane. + * + * @return {Drupal.verticalTab} + * The verticalTab instance. + */ + tabHide: function () { + // Hide this tab. + this.item.hide(); + // Update .first marker for items. We need recurse from parent to retain + // the actual DOM element order as jQuery implements sortOrder, but not + // as public method. + this.item.parent().children('.vertical-tabs__menu-item').removeClass('first') + .filter(':visible').eq(0).addClass('first'); + // Hide the details element. + this.details.addClass('vertical-tab--hidden').hide(); + // Focus the first visible tab (if there is one). + var $firstTab = this.details.siblings('.vertical-tabs__pane:not(.vertical-tab--hidden)').eq(0); + if ($firstTab.length) { + $firstTab.data('verticalTab').focus(); + } + // Hide the vertical tabs (if no tabs remain). + else { + this.item.closest('.js-form-type-vertical-tabs').hide(); + } + return this; + } + }; + + /** + * Theme function for a vertical tab. + * + * @param {object} settings + * An object with the following keys: + * @param {string} settings.title + * The name of the tab. + * + * @return {object} + * This function has to return an object with at least these keys: + * - item: The root tab jQuery element + * - link: The anchor tag that acts as the clickable area of the tab + * (jQuery version) + * - summary: The jQuery element that contains the tab summary + */ + Drupal.theme.verticalTab = function (settings) { + var tab = {}; + tab.item = $('<li class="vertical-tabs__menu-item" tabindex="-1"></li>') + .append(tab.link = $('<a href="#"></a>') + .append(tab.title = $('<strong class="vertical-tabs__menu-item-title"></strong>').text(settings.title)) + .append(tab.summary = $('<span class="vertical-tabs__menu-item-summary"></span>') + ) + ); + return tab; + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/misc/vertical-tabs.js b/core/misc/vertical-tabs.js index c7ad2fd2b44b..8f6a395b4924 100644 --- a/core/misc/vertical-tabs.js +++ b/core/misc/vertical-tabs.js @@ -1,37 +1,17 @@ /** - * @file - * Define vertical tabs functionality. - */ - -/** - * Triggers when form values inside a vertical tab changes. - * - * This is used to update the summary in vertical tabs in order to know what - * are the important fields' values. - * - * @event summaryUpdated - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./misc/vertical-tabs.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * This script transforms a set of details into a stack of vertical tabs. - * - * Each tab may have a summary which can be updated by another - * script. For that to work, each details element has an associated - * 'verticalTabCallback' (with jQuery.data() attached to the details), - * which is called every time the user performs an update to a form - * element inside the tab pane. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behaviors for vertical tabs. - */ Drupal.behaviors.verticalTabs = { - attach: function (context) { + attach: function attach(context) { var width = drupalSettings.widthBreakpoint || 640; var mq = '(max-width: ' + width + 'px)'; @@ -44,18 +24,14 @@ var focusID = $this.find(':hidden.vertical-tabs__active-tab').val(); var tab_focus; - // Check if there are some details that can be converted to - // vertical-tabs. var $details = $this.find('> details'); if ($details.length === 0) { return; } - // Create the tab column. var tab_list = $('<ul class="vertical-tabs__menu"></ul>'); $this.wrap('<div class="vertical-tabs clearfix"></div>').before(tab_list); - // Transform each details into a tab. $details.each(function () { var $that = $(this); var vertical_tab = new Drupal.verticalTab({ @@ -63,13 +39,7 @@ details: $that }); tab_list.append(vertical_tab.item); - $that - .removeClass('collapsed') - // prop() can't be used on browsers not supporting details element, - // the style won't apply to them if prop() is used. - .attr('open', true) - .addClass('vertical-tabs__pane') - .data('verticalTab', vertical_tab); + $that.removeClass('collapsed').attr('open', true).addClass('vertical-tabs__pane').data('verticalTab', vertical_tab); if (this.id === focusID) { tab_focus = $that; } @@ -79,13 +49,10 @@ $(tab_list).find('> li').eq(-1).addClass('last'); if (!tab_focus) { - // If the current URL has a fragment and one of the tabs contains an - // element that matches the URL fragment, activate that tab. var $locationHash = $this.find(window.location.hash); if (window.location.hash && $locationHash.length) { tab_focus = $locationHash.closest('.vertical-tabs__pane'); - } - else { + } else { tab_focus = $this.find('> .vertical-tabs__pane').eq(0); } } @@ -96,22 +63,6 @@ } }; - /** - * The vertical tab object represents a single tab within a tab group. - * - * @constructor - * - * @param {object} settings - * Settings object. - * @param {string} settings.title - * The name of the tab. - * @param {jQuery} settings.details - * The jQuery object of the details element that is the tab pane. - * - * @fires event:summaryUpdated - * - * @listens event:summaryUpdated - */ Drupal.verticalTab = function (settings) { var self = this; $.extend(this, settings, Drupal.theme('verticalTab', settings)); @@ -123,130 +74,70 @@ self.focus(); }); - // Keyboard events added: - // Pressing the Enter key will open the tab pane. this.link.on('keydown', function (event) { if (event.keyCode === 13) { event.preventDefault(); self.focus(); - // Set focus on the first input field of the visible details/tab pane. + $('.vertical-tabs__pane :input:visible:enabled').eq(0).trigger('focus'); } }); - this.details - .on('summaryUpdated', function () { - self.updateSummary(); - }) - .trigger('summaryUpdated'); + this.details.on('summaryUpdated', function () { + self.updateSummary(); + }).trigger('summaryUpdated'); }; Drupal.verticalTab.prototype = { - - /** - * Displays the tab's content pane. - */ - focus: function () { - this.details - .siblings('.vertical-tabs__pane') - .each(function () { - var tab = $(this).data('verticalTab'); - tab.details.hide(); - tab.item.removeClass('is-selected'); - }) - .end() - .show() - .siblings(':hidden.vertical-tabs__active-tab') - .val(this.details.attr('id')); + focus: function focus() { + this.details.siblings('.vertical-tabs__pane').each(function () { + var tab = $(this).data('verticalTab'); + tab.details.hide(); + tab.item.removeClass('is-selected'); + }).end().show().siblings(':hidden.vertical-tabs__active-tab').val(this.details.attr('id')); this.item.addClass('is-selected'); - // Mark the active tab for screen readers. + $('#active-vertical-tab').remove(); this.link.append('<span id="active-vertical-tab" class="visually-hidden">' + Drupal.t('(active tab)') + '</span>'); }, - /** - * Updates the tab's summary. - */ - updateSummary: function () { + updateSummary: function updateSummary() { this.summary.html(this.details.drupalGetSummary()); }, - /** - * Shows a vertical tab pane. - * - * @return {Drupal.verticalTab} - * The verticalTab instance. - */ - tabShow: function () { - // Display the tab. + tabShow: function tabShow() { this.item.show(); - // Show the vertical tabs. + this.item.closest('.js-form-type-vertical-tabs').show(); - // Update .first marker for items. We need recurse from parent to retain - // the actual DOM element order as jQuery implements sortOrder, but not - // as public method. - this.item.parent().children('.vertical-tabs__menu-item').removeClass('first') - .filter(':visible').eq(0).addClass('first'); - // Display the details element. + + this.item.parent().children('.vertical-tabs__menu-item').removeClass('first').filter(':visible').eq(0).addClass('first'); + this.details.removeClass('vertical-tab--hidden').show(); - // Focus this tab. + this.focus(); return this; }, - /** - * Hides a vertical tab pane. - * - * @return {Drupal.verticalTab} - * The verticalTab instance. - */ - tabHide: function () { - // Hide this tab. + tabHide: function tabHide() { this.item.hide(); - // Update .first marker for items. We need recurse from parent to retain - // the actual DOM element order as jQuery implements sortOrder, but not - // as public method. - this.item.parent().children('.vertical-tabs__menu-item').removeClass('first') - .filter(':visible').eq(0).addClass('first'); - // Hide the details element. + + this.item.parent().children('.vertical-tabs__menu-item').removeClass('first').filter(':visible').eq(0).addClass('first'); + this.details.addClass('vertical-tab--hidden').hide(); - // Focus the first visible tab (if there is one). + var $firstTab = this.details.siblings('.vertical-tabs__pane:not(.vertical-tab--hidden)').eq(0); if ($firstTab.length) { $firstTab.data('verticalTab').focus(); - } - // Hide the vertical tabs (if no tabs remain). - else { - this.item.closest('.js-form-type-vertical-tabs').hide(); - } + } else { + this.item.closest('.js-form-type-vertical-tabs').hide(); + } return this; } }; - /** - * Theme function for a vertical tab. - * - * @param {object} settings - * An object with the following keys: - * @param {string} settings.title - * The name of the tab. - * - * @return {object} - * This function has to return an object with at least these keys: - * - item: The root tab jQuery element - * - link: The anchor tag that acts as the clickable area of the tab - * (jQuery version) - * - summary: The jQuery element that contains the tab summary - */ Drupal.theme.verticalTab = function (settings) { var tab = {}; - tab.item = $('<li class="vertical-tabs__menu-item" tabindex="-1"></li>') - .append(tab.link = $('<a href="#"></a>') - .append(tab.title = $('<strong class="vertical-tabs__menu-item-title"></strong>').text(settings.title)) - .append(tab.summary = $('<span class="vertical-tabs__menu-item-summary"></span>') - ) - ); + tab.item = $('<li class="vertical-tabs__menu-item" tabindex="-1"></li>').append(tab.link = $('<a href="#"></a>').append(tab.title = $('<strong class="vertical-tabs__menu-item-title"></strong>').text(settings.title)).append(tab.summary = $('<span class="vertical-tabs__menu-item-summary"></span>'))); return tab; }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/big_pipe/js/big_pipe.es6.js b/core/modules/big_pipe/js/big_pipe.es6.js new file mode 100644 index 000000000000..cdfd766f1345 --- /dev/null +++ b/core/modules/big_pipe/js/big_pipe.es6.js @@ -0,0 +1,110 @@ +/** + * @file + * Renders BigPipe placeholders using Drupal's Ajax system. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Executes Ajax commands in <script type="application/vnd.drupal-ajax"> tag. + * + * These Ajax commands replace placeholders with HTML and load missing CSS/JS. + * + * @param {number} index + * Current index. + * @param {HTMLScriptElement} placeholderReplacement + * Script tag created by BigPipe. + */ + function bigPipeProcessPlaceholderReplacement(index, placeholderReplacement) { + var placeholderId = placeholderReplacement.getAttribute('data-big-pipe-replacement-for-placeholder-with-id'); + var content = this.textContent.trim(); + // Ignore any placeholders that are not in the known placeholder list. Used + // to avoid someone trying to XSS the site via the placeholdering mechanism. + if (typeof drupalSettings.bigPipePlaceholderIds[placeholderId] !== 'undefined') { + // If we try to parse the content too early (when the JSON containing Ajax + // commands is still arriving), textContent will be empty which will cause + // JSON.parse() to fail. Remove once so that it can be processed again + // later. + // @see bigPipeProcessDocument() + if (content === '') { + $(this).removeOnce('big-pipe'); + } + else { + var response = JSON.parse(content); + // Create a Drupal.Ajax object without associating an element, a + // progress indicator or a URL. + var ajaxObject = Drupal.ajax({ + url: '', + base: false, + element: false, + progress: false + }); + // Then, simulate an AJAX response having arrived, and let the Ajax + // system handle it. + ajaxObject.success(response, 'success'); + } + } + } + + /** + * Processes a streamed HTML document receiving placeholder replacements. + * + * @param {HTMLDocument} context + * The HTML document containing <script type="application/vnd.drupal-ajax"> + * tags generated by BigPipe. + * + * @return {bool} + * Returns true when processing has been finished and a stop signal has been + * found. + */ + function bigPipeProcessDocument(context) { + // Make sure we have BigPipe-related scripts before processing further. + if (!context.querySelector('script[data-big-pipe-event="start"]')) { + return false; + } + + $(context).find('script[data-big-pipe-replacement-for-placeholder-with-id]') + .once('big-pipe') + .each(bigPipeProcessPlaceholderReplacement); + + // If we see the stop signal, clear the timeout: all placeholder + // replacements are guaranteed to be received and processed. + if (context.querySelector('script[data-big-pipe-event="stop"]')) { + if (timeoutID) { + clearTimeout(timeoutID); + } + return true; + } + + return false; + } + + function bigPipeProcess() { + timeoutID = setTimeout(function () { + if (!bigPipeProcessDocument(document)) { + bigPipeProcess(); + } + }, interval); + } + + // The frequency with which to check for newly arrived BigPipe placeholders. + // Hence 50 ms means we check 20 times per second. Setting this to 100 ms or + // more would cause the user to see content appear noticeably slower. + var interval = drupalSettings.bigPipeInterval || 50; + // The internal ID to contain the watcher service. + var timeoutID; + + bigPipeProcess(); + + // If something goes wrong, make sure everything is cleaned up and has had a + // chance to be processed with everything loaded. + $(window).on('load', function () { + if (timeoutID) { + clearTimeout(timeoutID); + } + bigPipeProcessDocument(document); + }); + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/big_pipe/js/big_pipe.js b/core/modules/big_pipe/js/big_pipe.js index cdfd766f1345..3e6f20a09339 100644 --- a/core/modules/big_pipe/js/big_pipe.js +++ b/core/modules/big_pipe/js/big_pipe.js @@ -1,76 +1,44 @@ /** - * @file - * Renders BigPipe placeholders using Drupal's Ajax system. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/big_pipe/js/big_pipe.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Executes Ajax commands in <script type="application/vnd.drupal-ajax"> tag. - * - * These Ajax commands replace placeholders with HTML and load missing CSS/JS. - * - * @param {number} index - * Current index. - * @param {HTMLScriptElement} placeholderReplacement - * Script tag created by BigPipe. - */ function bigPipeProcessPlaceholderReplacement(index, placeholderReplacement) { var placeholderId = placeholderReplacement.getAttribute('data-big-pipe-replacement-for-placeholder-with-id'); var content = this.textContent.trim(); - // Ignore any placeholders that are not in the known placeholder list. Used - // to avoid someone trying to XSS the site via the placeholdering mechanism. + if (typeof drupalSettings.bigPipePlaceholderIds[placeholderId] !== 'undefined') { - // If we try to parse the content too early (when the JSON containing Ajax - // commands is still arriving), textContent will be empty which will cause - // JSON.parse() to fail. Remove once so that it can be processed again - // later. - // @see bigPipeProcessDocument() if (content === '') { $(this).removeOnce('big-pipe'); - } - else { + } else { var response = JSON.parse(content); - // Create a Drupal.Ajax object without associating an element, a - // progress indicator or a URL. + var ajaxObject = Drupal.ajax({ url: '', base: false, element: false, progress: false }); - // Then, simulate an AJAX response having arrived, and let the Ajax - // system handle it. + ajaxObject.success(response, 'success'); } } } - /** - * Processes a streamed HTML document receiving placeholder replacements. - * - * @param {HTMLDocument} context - * The HTML document containing <script type="application/vnd.drupal-ajax"> - * tags generated by BigPipe. - * - * @return {bool} - * Returns true when processing has been finished and a stop signal has been - * found. - */ function bigPipeProcessDocument(context) { - // Make sure we have BigPipe-related scripts before processing further. if (!context.querySelector('script[data-big-pipe-event="start"]')) { return false; } - $(context).find('script[data-big-pipe-replacement-for-placeholder-with-id]') - .once('big-pipe') - .each(bigPipeProcessPlaceholderReplacement); + $(context).find('script[data-big-pipe-replacement-for-placeholder-with-id]').once('big-pipe').each(bigPipeProcessPlaceholderReplacement); - // If we see the stop signal, clear the timeout: all placeholder - // replacements are guaranteed to be received and processed. if (context.querySelector('script[data-big-pipe-event="stop"]')) { if (timeoutID) { clearTimeout(timeoutID); @@ -89,22 +57,16 @@ }, interval); } - // The frequency with which to check for newly arrived BigPipe placeholders. - // Hence 50 ms means we check 20 times per second. Setting this to 100 ms or - // more would cause the user to see content appear noticeably slower. var interval = drupalSettings.bigPipeInterval || 50; - // The internal ID to contain the watcher service. + var timeoutID; bigPipeProcess(); - // If something goes wrong, make sure everything is cleaned up and has had a - // chance to be processed with everything loaded. $(window).on('load', function () { if (timeoutID) { clearTimeout(timeoutID); } bigPipeProcessDocument(document); }); - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/block/js/block.admin.es6.js b/core/modules/block/js/block.admin.es6.js new file mode 100644 index 000000000000..9f99b7fabaf0 --- /dev/null +++ b/core/modules/block/js/block.admin.es6.js @@ -0,0 +1,97 @@ +/** + * @file + * Block admin behaviors. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Filters the block list by a text input search string. + * + * The text input will have the selector `input.block-filter-text`. + * + * The target element to do searching in will be in the selector + * `input.block-filter-text[data-element]` + * + * The text source where the text should be found will have the selector + * `.block-filter-text-source` + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior for the block filtering. + */ + Drupal.behaviors.blockFilterByText = { + attach: function (context, settings) { + var $input = $('input.block-filter-text').once('block-filter-text'); + var $table = $($input.attr('data-element')); + var $filter_rows; + + /** + * Filters the block list. + * + * @param {jQuery.Event} e + * The jQuery event for the keyup event that triggered the filter. + */ + function filterBlockList(e) { + var query = $(e.target).val().toLowerCase(); + + /** + * Shows or hides the block entry based on the query. + * + * @param {number} index + * The index in the loop, as provided by `jQuery.each` + * @param {HTMLElement} label + * The label of the block. + */ + function toggleBlockEntry(index, label) { + var $label = $(label); + var $row = $label.parent().parent(); + var textMatch = $label.text().toLowerCase().indexOf(query) !== -1; + $row.toggle(textMatch); + } + + // Filter if the length of the query is at least 2 characters. + if (query.length >= 2) { + $filter_rows.each(toggleBlockEntry); + } + else { + $filter_rows.each(function (index) { + $(this).parent().parent().show(); + }); + } + } + + if ($table.length) { + $filter_rows = $table.find('div.block-filter-text-source'); + $input.on('keyup', filterBlockList); + } + } + }; + + /** + * Highlights the block that was just placed into the block listing. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior for the block placement highlighting. + */ + Drupal.behaviors.blockHighlightPlacement = { + attach: function (context, settings) { + if (settings.blockPlacement) { + $(context).find('[data-drupal-selector="edit-blocks"]').once('block-highlight').each(function () { + var $container = $(this); + // Just scrolling the document.body will not work in Firefox. The html + // element is needed as well. + $('html, body').animate({ + scrollTop: $('.js-block-placed').offset().top - $container.offset().top + $container.scrollTop() + }, 500); + }); + } + } + }; + +}(jQuery, Drupal)); diff --git a/core/modules/block/js/block.admin.js b/core/modules/block/js/block.admin.js index 9f99b7fabaf0..4347ab5af78f 100644 --- a/core/modules/block/js/block.admin.js +++ b/core/modules/block/js/block.admin.js @@ -1,51 +1,24 @@ /** - * @file - * Block admin behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/block/js/block.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Filters the block list by a text input search string. - * - * The text input will have the selector `input.block-filter-text`. - * - * The target element to do searching in will be in the selector - * `input.block-filter-text[data-element]` - * - * The text source where the text should be found will have the selector - * `.block-filter-text-source` - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the behavior for the block filtering. - */ Drupal.behaviors.blockFilterByText = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $input = $('input.block-filter-text').once('block-filter-text'); var $table = $($input.attr('data-element')); var $filter_rows; - /** - * Filters the block list. - * - * @param {jQuery.Event} e - * The jQuery event for the keyup event that triggered the filter. - */ function filterBlockList(e) { var query = $(e.target).val().toLowerCase(); - /** - * Shows or hides the block entry based on the query. - * - * @param {number} index - * The index in the loop, as provided by `jQuery.each` - * @param {HTMLElement} label - * The label of the block. - */ function toggleBlockEntry(index, label) { var $label = $(label); var $row = $label.parent().parent(); @@ -53,11 +26,9 @@ $row.toggle(textMatch); } - // Filter if the length of the query is at least 2 characters. if (query.length >= 2) { $filter_rows.each(toggleBlockEntry); - } - else { + } else { $filter_rows.each(function (index) { $(this).parent().parent().show(); }); @@ -71,21 +42,12 @@ } }; - /** - * Highlights the block that was just placed into the block listing. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the behavior for the block placement highlighting. - */ Drupal.behaviors.blockHighlightPlacement = { - attach: function (context, settings) { + attach: function attach(context, settings) { if (settings.blockPlacement) { $(context).find('[data-drupal-selector="edit-blocks"]').once('block-highlight').each(function () { var $container = $(this); - // Just scrolling the document.body will not work in Firefox. The html - // element is needed as well. + $('html, body').animate({ scrollTop: $('.js-block-placed').offset().top - $container.offset().top + $container.scrollTop() }, 500); @@ -93,5 +55,4 @@ } } }; - -}(jQuery, Drupal)); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/block/js/block.es6.js b/core/modules/block/js/block.es6.js new file mode 100644 index 000000000000..f27652736559 --- /dev/null +++ b/core/modules/block/js/block.es6.js @@ -0,0 +1,228 @@ +/** + * @file + * Block behaviors. + */ + +(function ($, window, Drupal) { + + 'use strict'; + + /** + * Provide the summary information for the block settings vertical tabs. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior for the block settings summaries. + */ + Drupal.behaviors.blockSettingsSummary = { + attach: function () { + // The drupalSetSummary method required for this behavior is not available + // on the Blocks administration page, so we need to make sure this + // behavior is processed only if drupalSetSummary is defined. + if (typeof $.fn.drupalSetSummary === 'undefined') { + return; + } + + /** + * Create a summary for checkboxes in the provided context. + * + * @param {HTMLDocument|HTMLElement} context + * A context where one would find checkboxes to summarize. + * + * @return {string} + * A string with the summary. + */ + function checkboxesSummary(context) { + var vals = []; + var $checkboxes = $(context).find('input[type="checkbox"]:checked + label'); + var il = $checkboxes.length; + for (var i = 0; i < il; i++) { + vals.push($($checkboxes[i]).html()); + } + if (!vals.length) { + vals.push(Drupal.t('Not restricted')); + } + return vals.join(', '); + } + + $('[data-drupal-selector="edit-visibility-node-type"], [data-drupal-selector="edit-visibility-language"], [data-drupal-selector="edit-visibility-user-role"]').drupalSetSummary(checkboxesSummary); + + $('[data-drupal-selector="edit-visibility-request-path"]').drupalSetSummary(function (context) { + var $pages = $(context).find('textarea[name="visibility[request_path][pages]"]'); + if (!$pages.val()) { + return Drupal.t('Not restricted'); + } + else { + return Drupal.t('Restricted to certain pages'); + } + }); + } + }; + + /** + * Move a block in the blocks table between regions via select list. + * + * This behavior is dependent on the tableDrag behavior, since it uses the + * objects initialized in that behavior to update the row. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the tableDrag behaviour for blocks in block administration. + */ + Drupal.behaviors.blockDrag = { + attach: function (context, settings) { + // tableDrag is required and we should be on the blocks admin page. + if (typeof Drupal.tableDrag === 'undefined' || typeof Drupal.tableDrag.blocks === 'undefined') { + return; + } + + /** + * Function to check empty regions and toggle classes based on this. + * + * @param {jQuery} table + * The jQuery object representing the table to inspect. + * @param {jQuery} rowObject + * The jQuery object representing the table row. + */ + function checkEmptyRegions(table, rowObject) { + table.find('tr.region-message').each(function () { + var $this = $(this); + // If the dragged row is in this region, but above the message row, + // swap it down one space. + if ($this.prev('tr').get(0) === rowObject.element) { + // Prevent a recursion problem when using the keyboard to move rows + // up. + if ((rowObject.method !== 'keyboard' || rowObject.direction === 'down')) { + rowObject.swap('after', this); + } + } + // This region has become empty. + if ($this.next('tr').is(':not(.draggable)') || $this.next('tr').length === 0) { + $this.removeClass('region-populated').addClass('region-empty'); + } + // This region has become populated. + else if ($this.is('.region-empty')) { + $this.removeClass('region-empty').addClass('region-populated'); + } + }); + } + + /** + * Function to update the last placed row with the correct classes. + * + * @param {jQuery} table + * The jQuery object representing the table to inspect. + * @param {jQuery} rowObject + * The jQuery object representing the table row. + */ + function updateLastPlaced(table, rowObject) { + // Remove the color-success class from new block if applicable. + table.find('.color-success').removeClass('color-success'); + + var $rowObject = $(rowObject); + if (!$rowObject.is('.drag-previous')) { + table.find('.drag-previous').removeClass('drag-previous'); + $rowObject.addClass('drag-previous'); + } + } + + /** + * Update block weights in the given region. + * + * @param {jQuery} table + * Table with draggable items. + * @param {string} region + * Machine name of region containing blocks to update. + */ + function updateBlockWeights(table, region) { + // Calculate minimum weight. + var weight = -Math.round(table.find('.draggable').length / 2); + // Update the block weights. + table.find('.region-' + region + '-message').nextUntil('.region-title') + .find('select.block-weight').val(function () { + // Increment the weight before assigning it to prevent using the + // absolute minimum available weight. This way we always have an + // unused upper and lower bound, which makes manually setting the + // weights easier for users who prefer to do it that way. + return ++weight; + }); + } + + var table = $('#blocks'); + // Get the blocks tableDrag object. + var tableDrag = Drupal.tableDrag.blocks; + // Add a handler for when a row is swapped, update empty regions. + tableDrag.row.prototype.onSwap = function (swappedRow) { + checkEmptyRegions(table, this); + updateLastPlaced(table, this); + }; + + // Add a handler so when a row is dropped, update fields dropped into + // new regions. + tableDrag.onDrop = function () { + var dragObject = this; + var $rowElement = $(dragObject.rowObject.element); + // Use "region-message" row instead of "region" row because + // "region-{region_name}-message" is less prone to regexp match errors. + var regionRow = $rowElement.prevAll('tr.region-message').get(0); + var regionName = regionRow.className.replace(/([^ ]+[ ]+)*region-([^ ]+)-message([ ]+[^ ]+)*/, '$2'); + var regionField = $rowElement.find('select.block-region-select'); + // Check whether the newly picked region is available for this block. + if (regionField.find('option[value=' + regionName + ']').length === 0) { + // If not, alert the user and keep the block in its old region + // setting. + window.alert(Drupal.t('The block cannot be placed in this region.')); + // Simulate that there was a selected element change, so the row is + // put back to from where the user tried to drag it. + regionField.trigger('change'); + } + + // Update region and weight fields if the region has been changed. + if (!regionField.is('.block-region-' + regionName)) { + var weightField = $rowElement.find('select.block-weight'); + var oldRegionName = weightField[0].className.replace(/([^ ]+[ ]+)*block-weight-([^ ]+)([ ]+[^ ]+)*/, '$2'); + regionField.removeClass('block-region-' + oldRegionName).addClass('block-region-' + regionName); + weightField.removeClass('block-weight-' + oldRegionName).addClass('block-weight-' + regionName); + regionField.val(regionName); + } + + updateBlockWeights(table, regionName); + }; + + // Add the behavior to each region select list. + $(context).find('select.block-region-select').once('block-region-select') + .on('change', function (event) { + // Make our new row and select field. + var row = $(this).closest('tr'); + var select = $(this); + // Find the correct region and insert the row as the last in the + // region. + tableDrag.rowObject = new tableDrag.row(row[0]); + var region_message = table.find('.region-' + select[0].value + '-message'); + var region_items = region_message.nextUntil('.region-message, .region-title'); + if (region_items.length) { + region_items.last().after(row); + } + // We found that region_message is the last row. + else { + region_message.after(row); + } + updateBlockWeights(table, select[0].value); + // Modify empty regions with added or removed fields. + checkEmptyRegions(table, tableDrag.rowObject); + // Update last placed block indication. + updateLastPlaced(table, row); + // Show unsaved changes warning. + if (!tableDrag.changed) { + $(Drupal.theme('tableDragChangedWarning')).insertBefore(tableDrag.table).hide().fadeIn('slow'); + tableDrag.changed = true; + } + // Remove focus from selectbox. + select.trigger('blur'); + }); + } + }; + +})(jQuery, window, Drupal); diff --git a/core/modules/block/js/block.js b/core/modules/block/js/block.js index f27652736559..74d4275586e2 100644 --- a/core/modules/block/js/block.js +++ b/core/modules/block/js/block.js @@ -1,38 +1,21 @@ /** - * @file - * Block behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/block/js/block.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, window, Drupal) { 'use strict'; - /** - * Provide the summary information for the block settings vertical tabs. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the behavior for the block settings summaries. - */ Drupal.behaviors.blockSettingsSummary = { - attach: function () { - // The drupalSetSummary method required for this behavior is not available - // on the Blocks administration page, so we need to make sure this - // behavior is processed only if drupalSetSummary is defined. + attach: function attach() { if (typeof $.fn.drupalSetSummary === 'undefined') { return; } - /** - * Create a summary for checkboxes in the provided context. - * - * @param {HTMLDocument|HTMLElement} context - * A context where one would find checkboxes to summarize. - * - * @return {string} - * A string with the summary. - */ function checkboxesSummary(context) { var vals = []; var $checkboxes = $(context).find('input[type="checkbox"]:checked + label'); @@ -52,73 +35,38 @@ var $pages = $(context).find('textarea[name="visibility[request_path][pages]"]'); if (!$pages.val()) { return Drupal.t('Not restricted'); - } - else { + } else { return Drupal.t('Restricted to certain pages'); } }); } }; - /** - * Move a block in the blocks table between regions via select list. - * - * This behavior is dependent on the tableDrag behavior, since it uses the - * objects initialized in that behavior to update the row. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the tableDrag behaviour for blocks in block administration. - */ Drupal.behaviors.blockDrag = { - attach: function (context, settings) { - // tableDrag is required and we should be on the blocks admin page. + attach: function attach(context, settings) { if (typeof Drupal.tableDrag === 'undefined' || typeof Drupal.tableDrag.blocks === 'undefined') { return; } - /** - * Function to check empty regions and toggle classes based on this. - * - * @param {jQuery} table - * The jQuery object representing the table to inspect. - * @param {jQuery} rowObject - * The jQuery object representing the table row. - */ function checkEmptyRegions(table, rowObject) { table.find('tr.region-message').each(function () { var $this = $(this); - // If the dragged row is in this region, but above the message row, - // swap it down one space. + if ($this.prev('tr').get(0) === rowObject.element) { - // Prevent a recursion problem when using the keyboard to move rows - // up. - if ((rowObject.method !== 'keyboard' || rowObject.direction === 'down')) { + if (rowObject.method !== 'keyboard' || rowObject.direction === 'down') { rowObject.swap('after', this); } } - // This region has become empty. + if ($this.next('tr').is(':not(.draggable)') || $this.next('tr').length === 0) { $this.removeClass('region-populated').addClass('region-empty'); - } - // This region has become populated. - else if ($this.is('.region-empty')) { - $this.removeClass('region-empty').addClass('region-populated'); - } + } else if ($this.is('.region-empty')) { + $this.removeClass('region-empty').addClass('region-populated'); + } }); } - /** - * Function to update the last placed row with the correct classes. - * - * @param {jQuery} table - * The jQuery object representing the table to inspect. - * @param {jQuery} rowObject - * The jQuery object representing the table row. - */ function updateLastPlaced(table, rowObject) { - // Remove the color-success class from new block if applicable. table.find('.color-success').removeClass('color-success'); var $rowObject = $(rowObject); @@ -128,58 +76,37 @@ } } - /** - * Update block weights in the given region. - * - * @param {jQuery} table - * Table with draggable items. - * @param {string} region - * Machine name of region containing blocks to update. - */ function updateBlockWeights(table, region) { - // Calculate minimum weight. var weight = -Math.round(table.find('.draggable').length / 2); - // Update the block weights. - table.find('.region-' + region + '-message').nextUntil('.region-title') - .find('select.block-weight').val(function () { - // Increment the weight before assigning it to prevent using the - // absolute minimum available weight. This way we always have an - // unused upper and lower bound, which makes manually setting the - // weights easier for users who prefer to do it that way. - return ++weight; - }); + + table.find('.region-' + region + '-message').nextUntil('.region-title').find('select.block-weight').val(function () { + return ++weight; + }); } var table = $('#blocks'); - // Get the blocks tableDrag object. + var tableDrag = Drupal.tableDrag.blocks; - // Add a handler for when a row is swapped, update empty regions. + tableDrag.row.prototype.onSwap = function (swappedRow) { checkEmptyRegions(table, this); updateLastPlaced(table, this); }; - // Add a handler so when a row is dropped, update fields dropped into - // new regions. tableDrag.onDrop = function () { var dragObject = this; var $rowElement = $(dragObject.rowObject.element); - // Use "region-message" row instead of "region" row because - // "region-{region_name}-message" is less prone to regexp match errors. + var regionRow = $rowElement.prevAll('tr.region-message').get(0); var regionName = regionRow.className.replace(/([^ ]+[ ]+)*region-([^ ]+)-message([ ]+[^ ]+)*/, '$2'); var regionField = $rowElement.find('select.block-region-select'); - // Check whether the newly picked region is available for this block. + if (regionField.find('option[value=' + regionName + ']').length === 0) { - // If not, alert the user and keep the block in its old region - // setting. window.alert(Drupal.t('The block cannot be placed in this region.')); - // Simulate that there was a selected element change, so the row is - // put back to from where the user tried to drag it. + regionField.trigger('change'); } - // Update region and weight fields if the region has been changed. if (!regionField.is('.block-region-' + regionName)) { var weightField = $rowElement.find('select.block-weight'); var oldRegionName = weightField[0].className.replace(/([^ ]+[ ]+)*block-weight-([^ ]+)([ ]+[^ ]+)*/, '$2'); @@ -191,38 +118,31 @@ updateBlockWeights(table, regionName); }; - // Add the behavior to each region select list. - $(context).find('select.block-region-select').once('block-region-select') - .on('change', function (event) { - // Make our new row and select field. - var row = $(this).closest('tr'); - var select = $(this); - // Find the correct region and insert the row as the last in the - // region. - tableDrag.rowObject = new tableDrag.row(row[0]); - var region_message = table.find('.region-' + select[0].value + '-message'); - var region_items = region_message.nextUntil('.region-message, .region-title'); - if (region_items.length) { - region_items.last().after(row); - } - // We found that region_message is the last row. - else { + $(context).find('select.block-region-select').once('block-region-select').on('change', function (event) { + var row = $(this).closest('tr'); + var select = $(this); + + tableDrag.rowObject = new tableDrag.row(row[0]); + var region_message = table.find('.region-' + select[0].value + '-message'); + var region_items = region_message.nextUntil('.region-message, .region-title'); + if (region_items.length) { + region_items.last().after(row); + } else { region_message.after(row); } - updateBlockWeights(table, select[0].value); - // Modify empty regions with added or removed fields. - checkEmptyRegions(table, tableDrag.rowObject); - // Update last placed block indication. - updateLastPlaced(table, row); - // Show unsaved changes warning. - if (!tableDrag.changed) { - $(Drupal.theme('tableDragChangedWarning')).insertBefore(tableDrag.table).hide().fadeIn('slow'); - tableDrag.changed = true; - } - // Remove focus from selectbox. - select.trigger('blur'); - }); + updateBlockWeights(table, select[0].value); + + checkEmptyRegions(table, tableDrag.rowObject); + + updateLastPlaced(table, row); + + if (!tableDrag.changed) { + $(Drupal.theme('tableDragChangedWarning')).insertBefore(tableDrag.table).hide().fadeIn('slow'); + tableDrag.changed = true; + } + + select.trigger('blur'); + }); } }; - -})(jQuery, window, Drupal); +})(jQuery, window, Drupal); \ No newline at end of file diff --git a/core/modules/book/book.es6.js b/core/modules/book/book.es6.js new file mode 100644 index 000000000000..2cc788126479 --- /dev/null +++ b/core/modules/book/book.es6.js @@ -0,0 +1,37 @@ +/** + * @file + * Javascript behaviors for the Book module. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Adds summaries to the book outline form. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behavior to book outline forms. + */ + Drupal.behaviors.bookDetailsSummaries = { + attach: function (context) { + $(context).find('.book-outline-form').drupalSetSummary(function (context) { + var $select = $(context).find('.book-title-select'); + var val = $select.val(); + + if (val === '0') { + return Drupal.t('Not in book'); + } + else if (val === 'new') { + return Drupal.t('New book'); + } + else { + return Drupal.checkPlain($select.find(':selected').text()); + } + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/book/book.js b/core/modules/book/book.js index 2cc788126479..3e45441e29bd 100644 --- a/core/modules/book/book.js +++ b/core/modules/book/book.js @@ -1,37 +1,29 @@ /** - * @file - * Javascript behaviors for the Book module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/book/book.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Adds summaries to the book outline form. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behavior to book outline forms. - */ Drupal.behaviors.bookDetailsSummaries = { - attach: function (context) { + attach: function attach(context) { $(context).find('.book-outline-form').drupalSetSummary(function (context) { var $select = $(context).find('.book-title-select'); var val = $select.val(); if (val === '0') { return Drupal.t('Not in book'); - } - else if (val === 'new') { + } else if (val === 'new') { return Drupal.t('New book'); - } - else { + } else { return Drupal.checkPlain($select.find(':selected').text()); } }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/ckeditor/js/ckeditor.admin.es6.js b/core/modules/ckeditor/js/ckeditor.admin.es6.js new file mode 100644 index 000000000000..11fd369586e2 --- /dev/null +++ b/core/modules/ckeditor/js/ckeditor.admin.es6.js @@ -0,0 +1,499 @@ +/** + * @file + * CKEditor button and group configuration user interface. + */ + +(function ($, Drupal, drupalSettings, _) { + + 'use strict'; + + Drupal.ckeditor = Drupal.ckeditor || {}; + + /** + * Sets config behaviour and creates config views for the CKEditor toolbar. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches admin behaviour to the CKEditor buttons. + * @prop {Drupal~behaviorDetach} detach + * Detaches admin behaviour from the CKEditor buttons on 'unload'. + */ + Drupal.behaviors.ckeditorAdmin = { + attach: function (context) { + // Process the CKEditor configuration fragment once. + var $configurationForm = $(context).find('.ckeditor-toolbar-configuration').once('ckeditor-configuration'); + if ($configurationForm.length) { + var $textarea = $configurationForm + // Hide the textarea that contains the serialized representation of the + // CKEditor configuration. + .find('.js-form-item-editor-settings-toolbar-button-groups') + .hide() + // Return the textarea child node from this expression. + .find('textarea'); + + // The HTML for the CKEditor configuration is assembled on the server + // and sent to the client as a serialized DOM fragment. + $configurationForm.append(drupalSettings.ckeditor.toolbarAdmin); + + // Create a configuration model. + var model = Drupal.ckeditor.models.Model = new Drupal.ckeditor.Model({ + $textarea: $textarea, + activeEditorConfig: JSON.parse($textarea.val()), + hiddenEditorConfig: drupalSettings.ckeditor.hiddenCKEditorConfig + }); + + // Create the configuration Views. + var viewDefaults = { + model: model, + el: $('.ckeditor-toolbar-configuration') + }; + Drupal.ckeditor.views = { + controller: new Drupal.ckeditor.ControllerView(viewDefaults), + visualView: new Drupal.ckeditor.VisualView(viewDefaults), + keyboardView: new Drupal.ckeditor.KeyboardView(viewDefaults), + auralView: new Drupal.ckeditor.AuralView(viewDefaults) + }; + } + }, + detach: function (context, settings, trigger) { + // Early-return if the trigger for detachment is something else than + // unload. + if (trigger !== 'unload') { + return; + } + + // We're detaching because CKEditor as text editor has been disabled; this + // really means that all CKEditor toolbar buttons have been removed. + // Hence,all editor features will be removed, so any reactions from + // filters will be undone. + var $configurationForm = $(context).find('.ckeditor-toolbar-configuration').findOnce('ckeditor-configuration'); + if ($configurationForm.length && Drupal.ckeditor.models && Drupal.ckeditor.models.Model) { + var config = Drupal.ckeditor.models.Model.toJSON().activeEditorConfig; + var buttons = Drupal.ckeditor.views.controller.getButtonList(config); + var $activeToolbar = $('.ckeditor-toolbar-configuration').find('.ckeditor-toolbar-active'); + for (var i = 0; i < buttons.length; i++) { + $activeToolbar.trigger('CKEditorToolbarChanged', ['removed', buttons[i]]); + } + } + } + }; + + /** + * CKEditor configuration UI methods of Backbone objects. + * + * @namespace + */ + Drupal.ckeditor = { + + /** + * A hash of View instances. + * + * @type {object} + */ + views: {}, + + /** + * A hash of Model instances. + * + * @type {object} + */ + models: {}, + + /** + * Translates changes in CKEditor config DOM structure to the config model. + * + * If the button is moved within an existing group, the DOM structure is + * simply translated to a configuration model. If the button is moved into a + * new group placeholder, then a process is launched to name that group + * before the button move is translated into configuration. + * + * @param {Backbone.View} view + * The Backbone View that invoked this function. + * @param {jQuery} $button + * A jQuery set that contains an li element that wraps a button element. + * @param {function} callback + * A callback to invoke after the button group naming modal dialog has + * been closed. + * + */ + registerButtonMove: function (view, $button, callback) { + var $group = $button.closest('.ckeditor-toolbar-group'); + + // If dropped in a placeholder button group, the user must name it. + if ($group.hasClass('placeholder')) { + if (view.isProcessing) { + return; + } + view.isProcessing = true; + + Drupal.ckeditor.openGroupNameDialog(view, $group, callback); + } + else { + view.model.set('isDirty', true); + callback(true); + } + }, + + /** + * Translates changes in CKEditor config DOM structure to the config model. + * + * Each row has a placeholder group at the end of the row. A user may not + * move an existing button group past the placeholder group at the end of a + * row. + * + * @param {Backbone.View} view + * The Backbone View that invoked this function. + * @param {jQuery} $group + * A jQuery set that contains an li element that wraps a group of buttons. + */ + registerGroupMove: function (view, $group) { + // Remove placeholder classes if necessary. + var $row = $group.closest('.ckeditor-row'); + if ($row.hasClass('placeholder')) { + $row.removeClass('placeholder'); + } + // If there are any rows with just a placeholder group, mark the row as a + // placeholder. + $row.parent().children().each(function () { + $row = $(this); + if ($row.find('.ckeditor-toolbar-group').not('.placeholder').length === 0) { + $row.addClass('placeholder'); + } + }); + view.model.set('isDirty', true); + }, + + /** + * Opens a dialog with a form for changing the title of a button group. + * + * @param {Backbone.View} view + * The Backbone View that invoked this function. + * @param {jQuery} $group + * A jQuery set that contains an li element that wraps a group of buttons. + * @param {function} callback + * A callback to invoke after the button group naming modal dialog has + * been closed. + */ + openGroupNameDialog: function (view, $group, callback) { + callback = callback || function () {}; + + /** + * Validates the string provided as a button group title. + * + * @param {HTMLElement} form + * The form DOM element that contains the input with the new button + * group title string. + * + * @return {bool} + * Returns true when an error exists, otherwise returns false. + */ + function validateForm(form) { + if (form.elements[0].value.length === 0) { + var $form = $(form); + if (!$form.hasClass('errors')) { + $form + .addClass('errors') + .find('input') + .addClass('error') + .attr('aria-invalid', 'true'); + $('<div class=\"description\" >' + Drupal.t('Please provide a name for the button group.') + '</div>').insertAfter(form.elements[0]); + } + return true; + } + return false; + } + + /** + * Attempts to close the dialog; Validates user input. + * + * @param {string} action + * The dialog action chosen by the user: 'apply' or 'cancel'. + * @param {HTMLElement} form + * The form DOM element that contains the input with the new button + * group title string. + */ + function closeDialog(action, form) { + + /** + * Closes the dialog when the user cancels or supplies valid data. + */ + function shutdown() { + dialog.close(action); + + // The processing marker can be deleted since the dialog has been + // closed. + delete view.isProcessing; + } + + /** + * Applies a string as the name of a CKEditor button group. + * + * @param {jQuery} $group + * A jQuery set that contains an li element that wraps a group of + * buttons. + * @param {string} name + * The new name of the CKEditor button group. + */ + function namePlaceholderGroup($group, name) { + // If it's currently still a placeholder, then that means we're + // creating a new group, and we must do some extra work. + if ($group.hasClass('placeholder')) { + // Remove all whitespace from the name, lowercase it and ensure + // HTML-safe encoding, then use this as the group ID for CKEditor + // configuration UI accessibility purposes only. + var groupID = 'ckeditor-toolbar-group-aria-label-for-' + Drupal.checkPlain(name.toLowerCase().replace(/\s/g, '-')); + $group + // Update the group container. + .removeAttr('aria-label') + .attr('data-drupal-ckeditor-type', 'group') + .attr('tabindex', 0) + // Update the group heading. + .children('.ckeditor-toolbar-group-name') + .attr('id', groupID) + .end() + // Update the group items. + .children('.ckeditor-toolbar-group-buttons') + .attr('aria-labelledby', groupID); + } + + $group + .attr('data-drupal-ckeditor-toolbar-group-name', name) + .children('.ckeditor-toolbar-group-name') + .text(name); + } + + // Invoke a user-provided callback and indicate failure. + if (action === 'cancel') { + shutdown(); + callback(false, $group); + return; + } + + // Validate that a group name was provided. + if (form && validateForm(form)) { + return; + } + + // React to application of a valid group name. + if (action === 'apply') { + shutdown(); + // Apply the provided name to the button group label. + namePlaceholderGroup($group, Drupal.checkPlain(form.elements[0].value)); + // Remove placeholder classes so that new placeholders will be + // inserted. + $group.closest('.ckeditor-row.placeholder').addBack().removeClass('placeholder'); + + // Invoke a user-provided callback and indicate success. + callback(true, $group); + + // Signal that the active toolbar DOM structure has changed. + view.model.set('isDirty', true); + } + } + + // Create a Drupal dialog that will get a button group name from the user. + var $ckeditorButtonGroupNameForm = $(Drupal.theme('ckeditorButtonGroupNameForm')); + var dialog = Drupal.dialog($ckeditorButtonGroupNameForm.get(0), { + title: Drupal.t('Button group name'), + dialogClass: 'ckeditor-name-toolbar-group', + resizable: false, + buttons: [ + { + text: Drupal.t('Apply'), + click: function () { + closeDialog('apply', this); + }, + primary: true + }, + { + text: Drupal.t('Cancel'), + click: function () { + closeDialog('cancel'); + } + } + ], + open: function () { + var form = this; + var $form = $(this); + var $widget = $form.parent(); + $widget.find('.ui-dialog-titlebar-close').remove(); + // Set a click handler on the input and button in the form. + $widget.on('keypress.ckeditor', 'input, button', function (event) { + // React to enter key press. + if (event.keyCode === 13) { + var $target = $(event.currentTarget); + var data = $target.data('ui-button'); + var action = 'apply'; + // Assume 'apply', but take into account that the user might have + // pressed the enter key on the dialog buttons. + if (data && data.options && data.options.label) { + action = data.options.label.toLowerCase(); + } + closeDialog(action, form); + event.stopPropagation(); + event.stopImmediatePropagation(); + event.preventDefault(); + } + }); + // Announce to the user that a modal dialog is open. + var text = Drupal.t('Editing the name of the new button group in a dialog.'); + if (typeof $group.attr('data-drupal-ckeditor-toolbar-group-name') !== 'undefined') { + text = Drupal.t('Editing the name of the "@groupName" button group in a dialog.', { + '@groupName': $group.attr('data-drupal-ckeditor-toolbar-group-name') + }); + } + Drupal.announce(text); + }, + close: function (event) { + // Automatically destroy the DOM element that was used for the dialog. + $(event.target).remove(); + } + }); + // A modal dialog is used because the user must provide a button group + // name or cancel the button placement before taking any other action. + dialog.showModal(); + + $(document.querySelector('.ckeditor-name-toolbar-group').querySelector('input')) + // When editing, set the "group name" input in the form to the current + // value. + .attr('value', $group.attr('data-drupal-ckeditor-toolbar-group-name')) + // Focus on the "group name" input in the form. + .trigger('focus'); + } + + }; + + /** + * Automatically shows/hides settings of buttons-only CKEditor plugins. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches show/hide behaviour to Plugin Settings buttons. + */ + Drupal.behaviors.ckeditorAdminButtonPluginSettings = { + attach: function (context) { + var $context = $(context); + var $ckeditorPluginSettings = $context.find('#ckeditor-plugin-settings').once('ckeditor-plugin-settings'); + if ($ckeditorPluginSettings.length) { + // Hide all button-dependent plugin settings initially. + $ckeditorPluginSettings.find('[data-ckeditor-buttons]').each(function () { + var $this = $(this); + if ($this.data('verticalTab')) { + $this.data('verticalTab').tabHide(); + } + else { + // On very narrow viewports, Vertical Tabs are disabled. + $this.hide(); + } + $this.data('ckeditorButtonPluginSettingsActiveButtons', []); + }); + + // Whenever a button is added or removed, check if we should show or + // hide the corresponding plugin settings. (Note that upon + // initialization, each button that already is part of the toolbar still + // is considered "added", hence it also works correctly for buttons that + // were added previously.) + $context + .find('.ckeditor-toolbar-active') + .off('CKEditorToolbarChanged.ckeditorAdminPluginSettings') + .on('CKEditorToolbarChanged.ckeditorAdminPluginSettings', function (event, action, button) { + var $pluginSettings = $ckeditorPluginSettings + .find('[data-ckeditor-buttons~=' + button + ']'); + + // No settings for this button. + if ($pluginSettings.length === 0) { + return; + } + + var verticalTab = $pluginSettings.data('verticalTab'); + var activeButtons = $pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons'); + if (action === 'added') { + activeButtons.push(button); + // Show this plugin's settings if >=1 of its buttons are active. + if (verticalTab) { + verticalTab.tabShow(); + } + else { + // On very narrow viewports, Vertical Tabs remain fieldsets. + $pluginSettings.show(); + } + + } + else { + // Remove this button from the list of active buttons. + activeButtons.splice(activeButtons.indexOf(button), 1); + // Show this plugin's settings 0 of its buttons are active. + if (activeButtons.length === 0) { + if (verticalTab) { + verticalTab.tabHide(); + } + else { + // On very narrow viewports, Vertical Tabs are disabled. + $pluginSettings.hide(); + } + } + } + $pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons', activeButtons); + }); + } + } + }; + + /** + * Themes a blank CKEditor row. + * + * @return {string} + * A HTML string for a CKEditor row. + */ + Drupal.theme.ckeditorRow = function () { + return '<li class="ckeditor-row placeholder" role="group"><ul class="ckeditor-toolbar-groups clearfix"></ul></li>'; + }; + + /** + * Themes a blank CKEditor button group. + * + * @return {string} + * A HTML string for a CKEditor button group. + */ + Drupal.theme.ckeditorToolbarGroup = function () { + var group = ''; + group += '<li class="ckeditor-toolbar-group placeholder" role="presentation" aria-label="' + Drupal.t('Place a button to create a new button group.') + '">'; + group += '<h3 class="ckeditor-toolbar-group-name">' + Drupal.t('New group') + '</h3>'; + group += '<ul class="ckeditor-buttons ckeditor-toolbar-group-buttons" role="toolbar" data-drupal-ckeditor-button-sorting="target"></ul>'; + group += '</li>'; + return group; + }; + + /** + * Themes a form for changing the title of a CKEditor button group. + * + * @return {string} + * A HTML string for the form for the title of a CKEditor button group. + */ + Drupal.theme.ckeditorButtonGroupNameForm = function () { + return '<form><input name="group-name" required="required"></form>'; + }; + + /** + * Themes a button that will toggle the button group names in active config. + * + * @return {string} + * A HTML string for the button to toggle group names. + */ + Drupal.theme.ckeditorButtonGroupNamesToggle = function () { + return '<button class="link ckeditor-groupnames-toggle" aria-pressed="false"></button>'; + }; + + /** + * Themes a button that will prompt the user to name a new button group. + * + * @return {string} + * A HTML string for the button to create a name for a new button group. + */ + Drupal.theme.ckeditorNewButtonGroup = function () { + return '<li class="ckeditor-add-new-group"><button aria-label="' + Drupal.t('Add a CKEditor button group to the end of this row.') + '">' + Drupal.t('Add group') + '</button></li>'; + }; + +})(jQuery, Drupal, drupalSettings, _); diff --git a/core/modules/ckeditor/js/ckeditor.admin.js b/core/modules/ckeditor/js/ckeditor.admin.js index 11fd369586e2..1d8fea3a8fcf 100644 --- a/core/modules/ckeditor/js/ckeditor.admin.js +++ b/core/modules/ckeditor/js/ckeditor.admin.js @@ -1,7 +1,10 @@ /** - * @file - * CKEditor button and group configuration user interface. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/ckeditor.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings, _) { @@ -9,41 +12,20 @@ Drupal.ckeditor = Drupal.ckeditor || {}; - /** - * Sets config behaviour and creates config views for the CKEditor toolbar. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches admin behaviour to the CKEditor buttons. - * @prop {Drupal~behaviorDetach} detach - * Detaches admin behaviour from the CKEditor buttons on 'unload'. - */ Drupal.behaviors.ckeditorAdmin = { - attach: function (context) { - // Process the CKEditor configuration fragment once. + attach: function attach(context) { var $configurationForm = $(context).find('.ckeditor-toolbar-configuration').once('ckeditor-configuration'); if ($configurationForm.length) { - var $textarea = $configurationForm - // Hide the textarea that contains the serialized representation of the - // CKEditor configuration. - .find('.js-form-item-editor-settings-toolbar-button-groups') - .hide() - // Return the textarea child node from this expression. - .find('textarea'); - - // The HTML for the CKEditor configuration is assembled on the server - // and sent to the client as a serialized DOM fragment. + var $textarea = $configurationForm.find('.js-form-item-editor-settings-toolbar-button-groups').hide().find('textarea'); + $configurationForm.append(drupalSettings.ckeditor.toolbarAdmin); - // Create a configuration model. var model = Drupal.ckeditor.models.Model = new Drupal.ckeditor.Model({ $textarea: $textarea, activeEditorConfig: JSON.parse($textarea.val()), hiddenEditorConfig: drupalSettings.ckeditor.hiddenCKEditorConfig }); - // Create the configuration Views. var viewDefaults = { model: model, el: $('.ckeditor-toolbar-configuration') @@ -56,17 +38,11 @@ }; } }, - detach: function (context, settings, trigger) { - // Early-return if the trigger for detachment is something else than - // unload. + detach: function detach(context, settings, trigger) { if (trigger !== 'unload') { return; } - // We're detaching because CKEditor as text editor has been disabled; this - // really means that all CKEditor toolbar buttons have been removed. - // Hence,all editor features will be removed, so any reactions from - // filters will be undone. var $configurationForm = $(context).find('.ckeditor-toolbar-configuration').findOnce('ckeditor-configuration'); if ($configurationForm.length && Drupal.ckeditor.models && Drupal.ckeditor.models.Model) { var config = Drupal.ckeditor.models.Model.toJSON().activeEditorConfig; @@ -79,48 +55,14 @@ } }; - /** - * CKEditor configuration UI methods of Backbone objects. - * - * @namespace - */ Drupal.ckeditor = { - - /** - * A hash of View instances. - * - * @type {object} - */ views: {}, - /** - * A hash of Model instances. - * - * @type {object} - */ models: {}, - /** - * Translates changes in CKEditor config DOM structure to the config model. - * - * If the button is moved within an existing group, the DOM structure is - * simply translated to a configuration model. If the button is moved into a - * new group placeholder, then a process is launched to name that group - * before the button move is translated into configuration. - * - * @param {Backbone.View} view - * The Backbone View that invoked this function. - * @param {jQuery} $button - * A jQuery set that contains an li element that wraps a button element. - * @param {function} callback - * A callback to invoke after the button group naming modal dialog has - * been closed. - * - */ - registerButtonMove: function (view, $button, callback) { + registerButtonMove: function registerButtonMove(view, $button, callback) { var $group = $button.closest('.ckeditor-toolbar-group'); - // If dropped in a placeholder button group, the user must name it. if ($group.hasClass('placeholder')) { if (view.isProcessing) { return; @@ -128,33 +70,18 @@ view.isProcessing = true; Drupal.ckeditor.openGroupNameDialog(view, $group, callback); - } - else { + } else { view.model.set('isDirty', true); callback(true); } }, - /** - * Translates changes in CKEditor config DOM structure to the config model. - * - * Each row has a placeholder group at the end of the row. A user may not - * move an existing button group past the placeholder group at the end of a - * row. - * - * @param {Backbone.View} view - * The Backbone View that invoked this function. - * @param {jQuery} $group - * A jQuery set that contains an li element that wraps a group of buttons. - */ - registerGroupMove: function (view, $group) { - // Remove placeholder classes if necessary. + registerGroupMove: function registerGroupMove(view, $group) { var $row = $group.closest('.ckeditor-row'); if ($row.hasClass('placeholder')) { $row.removeClass('placeholder'); } - // If there are any rows with just a placeholder group, mark the row as a - // placeholder. + $row.parent().children().each(function () { $row = $(this); if ($row.find('.ckeditor-toolbar-group').not('.placeholder').length === 0) { @@ -164,39 +91,14 @@ view.model.set('isDirty', true); }, - /** - * Opens a dialog with a form for changing the title of a button group. - * - * @param {Backbone.View} view - * The Backbone View that invoked this function. - * @param {jQuery} $group - * A jQuery set that contains an li element that wraps a group of buttons. - * @param {function} callback - * A callback to invoke after the button group naming modal dialog has - * been closed. - */ - openGroupNameDialog: function (view, $group, callback) { + openGroupNameDialog: function openGroupNameDialog(view, $group, callback) { callback = callback || function () {}; - /** - * Validates the string provided as a button group title. - * - * @param {HTMLElement} form - * The form DOM element that contains the input with the new button - * group title string. - * - * @return {bool} - * Returns true when an error exists, otherwise returns false. - */ function validateForm(form) { if (form.elements[0].value.length === 0) { var $form = $(form); if (!$form.hasClass('errors')) { - $form - .addClass('errors') - .find('input') - .addClass('error') - .attr('aria-invalid', 'true'); + $form.addClass('errors').find('input').addClass('error').attr('aria-invalid', 'true'); $('<div class=\"description\" >' + Drupal.t('Please provide a name for the button group.') + '</div>').insertAfter(form.elements[0]); } return true; @@ -204,129 +106,74 @@ return false; } - /** - * Attempts to close the dialog; Validates user input. - * - * @param {string} action - * The dialog action chosen by the user: 'apply' or 'cancel'. - * @param {HTMLElement} form - * The form DOM element that contains the input with the new button - * group title string. - */ function closeDialog(action, form) { - - /** - * Closes the dialog when the user cancels or supplies valid data. - */ function shutdown() { dialog.close(action); - // The processing marker can be deleted since the dialog has been - // closed. delete view.isProcessing; } - /** - * Applies a string as the name of a CKEditor button group. - * - * @param {jQuery} $group - * A jQuery set that contains an li element that wraps a group of - * buttons. - * @param {string} name - * The new name of the CKEditor button group. - */ function namePlaceholderGroup($group, name) { - // If it's currently still a placeholder, then that means we're - // creating a new group, and we must do some extra work. if ($group.hasClass('placeholder')) { - // Remove all whitespace from the name, lowercase it and ensure - // HTML-safe encoding, then use this as the group ID for CKEditor - // configuration UI accessibility purposes only. var groupID = 'ckeditor-toolbar-group-aria-label-for-' + Drupal.checkPlain(name.toLowerCase().replace(/\s/g, '-')); - $group - // Update the group container. - .removeAttr('aria-label') - .attr('data-drupal-ckeditor-type', 'group') - .attr('tabindex', 0) - // Update the group heading. - .children('.ckeditor-toolbar-group-name') - .attr('id', groupID) - .end() - // Update the group items. - .children('.ckeditor-toolbar-group-buttons') - .attr('aria-labelledby', groupID); + $group.removeAttr('aria-label').attr('data-drupal-ckeditor-type', 'group').attr('tabindex', 0).children('.ckeditor-toolbar-group-name').attr('id', groupID).end().children('.ckeditor-toolbar-group-buttons').attr('aria-labelledby', groupID); } - $group - .attr('data-drupal-ckeditor-toolbar-group-name', name) - .children('.ckeditor-toolbar-group-name') - .text(name); + $group.attr('data-drupal-ckeditor-toolbar-group-name', name).children('.ckeditor-toolbar-group-name').text(name); } - // Invoke a user-provided callback and indicate failure. if (action === 'cancel') { shutdown(); callback(false, $group); return; } - // Validate that a group name was provided. if (form && validateForm(form)) { return; } - // React to application of a valid group name. if (action === 'apply') { shutdown(); - // Apply the provided name to the button group label. + namePlaceholderGroup($group, Drupal.checkPlain(form.elements[0].value)); - // Remove placeholder classes so that new placeholders will be - // inserted. + $group.closest('.ckeditor-row.placeholder').addBack().removeClass('placeholder'); - // Invoke a user-provided callback and indicate success. callback(true, $group); - // Signal that the active toolbar DOM structure has changed. view.model.set('isDirty', true); } } - // Create a Drupal dialog that will get a button group name from the user. var $ckeditorButtonGroupNameForm = $(Drupal.theme('ckeditorButtonGroupNameForm')); var dialog = Drupal.dialog($ckeditorButtonGroupNameForm.get(0), { title: Drupal.t('Button group name'), dialogClass: 'ckeditor-name-toolbar-group', resizable: false, - buttons: [ - { - text: Drupal.t('Apply'), - click: function () { - closeDialog('apply', this); - }, - primary: true + buttons: [{ + text: Drupal.t('Apply'), + click: function click() { + closeDialog('apply', this); }, - { - text: Drupal.t('Cancel'), - click: function () { - closeDialog('cancel'); - } + primary: true + }, { + text: Drupal.t('Cancel'), + click: function click() { + closeDialog('cancel'); } - ], - open: function () { + }], + open: function open() { var form = this; var $form = $(this); var $widget = $form.parent(); $widget.find('.ui-dialog-titlebar-close').remove(); - // Set a click handler on the input and button in the form. + $widget.on('keypress.ckeditor', 'input, button', function (event) { - // React to enter key press. if (event.keyCode === 13) { var $target = $(event.currentTarget); var data = $target.data('ui-button'); var action = 'apply'; - // Assume 'apply', but take into account that the user might have - // pressed the enter key on the dialog buttons. + if (data && data.options && data.options.label) { action = data.options.label.toLowerCase(); } @@ -336,7 +183,7 @@ event.preventDefault(); } }); - // Announce to the user that a modal dialog is open. + var text = Drupal.t('Editing the name of the new button group in a dialog.'); if (typeof $group.attr('data-drupal-ckeditor-toolbar-group-name') !== 'undefined') { text = Drupal.t('Editing the name of the "@groupName" button group in a dialog.', { @@ -345,118 +192,71 @@ } Drupal.announce(text); }, - close: function (event) { - // Automatically destroy the DOM element that was used for the dialog. + close: function close(event) { $(event.target).remove(); } }); - // A modal dialog is used because the user must provide a button group - // name or cancel the button placement before taking any other action. + dialog.showModal(); - $(document.querySelector('.ckeditor-name-toolbar-group').querySelector('input')) - // When editing, set the "group name" input in the form to the current - // value. - .attr('value', $group.attr('data-drupal-ckeditor-toolbar-group-name')) - // Focus on the "group name" input in the form. - .trigger('focus'); + $(document.querySelector('.ckeditor-name-toolbar-group').querySelector('input')).attr('value', $group.attr('data-drupal-ckeditor-toolbar-group-name')).trigger('focus'); } }; - /** - * Automatically shows/hides settings of buttons-only CKEditor plugins. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches show/hide behaviour to Plugin Settings buttons. - */ Drupal.behaviors.ckeditorAdminButtonPluginSettings = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); var $ckeditorPluginSettings = $context.find('#ckeditor-plugin-settings').once('ckeditor-plugin-settings'); if ($ckeditorPluginSettings.length) { - // Hide all button-dependent plugin settings initially. $ckeditorPluginSettings.find('[data-ckeditor-buttons]').each(function () { var $this = $(this); if ($this.data('verticalTab')) { $this.data('verticalTab').tabHide(); - } - else { - // On very narrow viewports, Vertical Tabs are disabled. + } else { $this.hide(); } $this.data('ckeditorButtonPluginSettingsActiveButtons', []); }); - // Whenever a button is added or removed, check if we should show or - // hide the corresponding plugin settings. (Note that upon - // initialization, each button that already is part of the toolbar still - // is considered "added", hence it also works correctly for buttons that - // were added previously.) - $context - .find('.ckeditor-toolbar-active') - .off('CKEditorToolbarChanged.ckeditorAdminPluginSettings') - .on('CKEditorToolbarChanged.ckeditorAdminPluginSettings', function (event, action, button) { - var $pluginSettings = $ckeditorPluginSettings - .find('[data-ckeditor-buttons~=' + button + ']'); - - // No settings for this button. - if ($pluginSettings.length === 0) { - return; - } + $context.find('.ckeditor-toolbar-active').off('CKEditorToolbarChanged.ckeditorAdminPluginSettings').on('CKEditorToolbarChanged.ckeditorAdminPluginSettings', function (event, action, button) { + var $pluginSettings = $ckeditorPluginSettings.find('[data-ckeditor-buttons~=' + button + ']'); - var verticalTab = $pluginSettings.data('verticalTab'); - var activeButtons = $pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons'); - if (action === 'added') { - activeButtons.push(button); - // Show this plugin's settings if >=1 of its buttons are active. - if (verticalTab) { - verticalTab.tabShow(); - } - else { - // On very narrow viewports, Vertical Tabs remain fieldsets. - $pluginSettings.show(); - } + if ($pluginSettings.length === 0) { + return; + } + + var verticalTab = $pluginSettings.data('verticalTab'); + var activeButtons = $pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons'); + if (action === 'added') { + activeButtons.push(button); + if (verticalTab) { + verticalTab.tabShow(); + } else { + $pluginSettings.show(); } - else { - // Remove this button from the list of active buttons. - activeButtons.splice(activeButtons.indexOf(button), 1); - // Show this plugin's settings 0 of its buttons are active. - if (activeButtons.length === 0) { - if (verticalTab) { - verticalTab.tabHide(); - } - else { - // On very narrow viewports, Vertical Tabs are disabled. - $pluginSettings.hide(); - } + } else { + activeButtons.splice(activeButtons.indexOf(button), 1); + + if (activeButtons.length === 0) { + if (verticalTab) { + verticalTab.tabHide(); + } else { + $pluginSettings.hide(); } } - $pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons', activeButtons); - }); + } + $pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons', activeButtons); + }); } } }; - /** - * Themes a blank CKEditor row. - * - * @return {string} - * A HTML string for a CKEditor row. - */ Drupal.theme.ckeditorRow = function () { return '<li class="ckeditor-row placeholder" role="group"><ul class="ckeditor-toolbar-groups clearfix"></ul></li>'; }; - /** - * Themes a blank CKEditor button group. - * - * @return {string} - * A HTML string for a CKEditor button group. - */ Drupal.theme.ckeditorToolbarGroup = function () { var group = ''; group += '<li class="ckeditor-toolbar-group placeholder" role="presentation" aria-label="' + Drupal.t('Place a button to create a new button group.') + '">'; @@ -466,34 +266,15 @@ return group; }; - /** - * Themes a form for changing the title of a CKEditor button group. - * - * @return {string} - * A HTML string for the form for the title of a CKEditor button group. - */ Drupal.theme.ckeditorButtonGroupNameForm = function () { return '<form><input name="group-name" required="required"></form>'; }; - /** - * Themes a button that will toggle the button group names in active config. - * - * @return {string} - * A HTML string for the button to toggle group names. - */ Drupal.theme.ckeditorButtonGroupNamesToggle = function () { return '<button class="link ckeditor-groupnames-toggle" aria-pressed="false"></button>'; }; - /** - * Themes a button that will prompt the user to name a new button group. - * - * @return {string} - * A HTML string for the button to create a name for a new button group. - */ Drupal.theme.ckeditorNewButtonGroup = function () { return '<li class="ckeditor-add-new-group"><button aria-label="' + Drupal.t('Add a CKEditor button group to the end of this row.') + '">' + Drupal.t('Add group') + '</button></li>'; }; - -})(jQuery, Drupal, drupalSettings, _); +})(jQuery, Drupal, drupalSettings, _); \ No newline at end of file diff --git a/core/modules/ckeditor/js/ckeditor.drupalimage.admin.es6.js b/core/modules/ckeditor/js/ckeditor.drupalimage.admin.es6.js new file mode 100644 index 000000000000..256bb41c02b4 --- /dev/null +++ b/core/modules/ckeditor/js/ckeditor.drupalimage.admin.es6.js @@ -0,0 +1,45 @@ +/** + * @file + * CKEditor 'drupalimage' plugin admin behavior. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Provides the summary for the "drupalimage" plugin settings vertical tab. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behaviour to the "drupalimage" settings vertical tab. + */ + Drupal.behaviors.ckeditorDrupalImageSettingsSummary = { + attach: function () { + $('[data-ckeditor-plugin-id="drupalimage"]').drupalSetSummary(function (context) { + var root = 'input[name="editor[settings][plugins][drupalimage][image_upload]'; + var $status = $(root + '[status]"]'); + var $maxFileSize = $(root + '[max_size]"]'); + var $maxWidth = $(root + '[max_dimensions][width]"]'); + var $maxHeight = $(root + '[max_dimensions][height]"]'); + var $scheme = $(root + '[scheme]"]:checked'); + + var maxFileSize = $maxFileSize.val() ? $maxFileSize.val() : $maxFileSize.attr('placeholder'); + var maxDimensions = ($maxWidth.val() && $maxHeight.val()) ? '(' + $maxWidth.val() + 'x' + $maxHeight.val() + ')' : ''; + + if (!$status.is(':checked')) { + return Drupal.t('Uploads disabled'); + } + + var output = ''; + output += Drupal.t('Uploads enabled, max size: @size @dimensions', {'@size': maxFileSize, '@dimensions': maxDimensions}); + if ($scheme.length) { + output += '<br />' + $scheme.attr('data-label'); + } + return output; + }); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/ckeditor/js/ckeditor.drupalimage.admin.js b/core/modules/ckeditor/js/ckeditor.drupalimage.admin.js index 256bb41c02b4..3f568178a5e6 100644 --- a/core/modules/ckeditor/js/ckeditor.drupalimage.admin.js +++ b/core/modules/ckeditor/js/ckeditor.drupalimage.admin.js @@ -1,22 +1,17 @@ /** - * @file - * CKEditor 'drupalimage' plugin admin behavior. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/ckeditor.drupalimage.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Provides the summary for the "drupalimage" plugin settings vertical tab. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behaviour to the "drupalimage" settings vertical tab. - */ Drupal.behaviors.ckeditorDrupalImageSettingsSummary = { - attach: function () { + attach: function attach() { $('[data-ckeditor-plugin-id="drupalimage"]').drupalSetSummary(function (context) { var root = 'input[name="editor[settings][plugins][drupalimage][image_upload]'; var $status = $(root + '[status]"]'); @@ -26,14 +21,14 @@ var $scheme = $(root + '[scheme]"]:checked'); var maxFileSize = $maxFileSize.val() ? $maxFileSize.val() : $maxFileSize.attr('placeholder'); - var maxDimensions = ($maxWidth.val() && $maxHeight.val()) ? '(' + $maxWidth.val() + 'x' + $maxHeight.val() + ')' : ''; + var maxDimensions = $maxWidth.val() && $maxHeight.val() ? '(' + $maxWidth.val() + 'x' + $maxHeight.val() + ')' : ''; if (!$status.is(':checked')) { return Drupal.t('Uploads disabled'); } var output = ''; - output += Drupal.t('Uploads enabled, max size: @size @dimensions', {'@size': maxFileSize, '@dimensions': maxDimensions}); + output += Drupal.t('Uploads enabled, max size: @size @dimensions', { '@size': maxFileSize, '@dimensions': maxDimensions }); if ($scheme.length) { output += '<br />' + $scheme.attr('data-label'); } @@ -41,5 +36,4 @@ }); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/ckeditor/js/ckeditor.es6.js b/core/modules/ckeditor/js/ckeditor.es6.js new file mode 100644 index 000000000000..2b469c8037c7 --- /dev/null +++ b/core/modules/ckeditor/js/ckeditor.es6.js @@ -0,0 +1,350 @@ +/** + * @file + * CKEditor implementation of {@link Drupal.editors} API. + */ + +(function (Drupal, debounce, CKEDITOR, $, displace, AjaxCommands) { + + 'use strict'; + + /** + * @namespace + */ + Drupal.editors.ckeditor = { + + /** + * Editor attach callback. + * + * @param {HTMLElement} element + * The element to attach the editor to. + * @param {string} format + * The text format for the editor. + * + * @return {bool} + * Whether the call to `CKEDITOR.replace()` created an editor or not. + */ + attach: function (element, format) { + this._loadExternalPlugins(format); + // Also pass settings that are Drupal-specific. + format.editorSettings.drupal = { + format: format.format + }; + + // Set a title on the CKEditor instance that includes the text field's + // label so that screen readers say something that is understandable + // for end users. + var label = $('label[for=' + element.getAttribute('id') + ']').html(); + format.editorSettings.title = Drupal.t('Rich Text Editor, !label field', {'!label': label}); + + return !!CKEDITOR.replace(element, format.editorSettings); + }, + + /** + * Editor detach callback. + * + * @param {HTMLElement} element + * The element to detach the editor from. + * @param {string} format + * The text format used for the editor. + * @param {string} trigger + * The event trigger for the detach. + * + * @return {bool} + * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()` + * found an editor or not. + */ + detach: function (element, format, trigger) { + var editor = CKEDITOR.dom.element.get(element).getEditor(); + if (editor) { + if (trigger === 'serialize') { + editor.updateElement(); + } + else { + editor.destroy(); + element.removeAttribute('contentEditable'); + } + } + return !!editor; + }, + + /** + * Reacts on a change in the editor element. + * + * @param {HTMLElement} element + * The element where the change occured. + * @param {function} callback + * Callback called with the value of the editor. + * + * @return {bool} + * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()` + * found an editor or not. + */ + onChange: function (element, callback) { + var editor = CKEDITOR.dom.element.get(element).getEditor(); + if (editor) { + editor.on('change', debounce(function () { + callback(editor.getData()); + }, 400)); + + // A temporary workaround to control scrollbar appearance when using + // autoGrow event to control editor's height. + // @todo Remove when http://dev.ckeditor.com/ticket/12120 is fixed. + editor.on('mode', function () { + var editable = editor.editable(); + if (!editable.isInline()) { + editor.on('autoGrow', function (evt) { + var doc = evt.editor.document; + var scrollable = CKEDITOR.env.quirks ? doc.getBody() : doc.getDocumentElement(); + + if (scrollable.$.scrollHeight < scrollable.$.clientHeight) { + scrollable.setStyle('overflow-y', 'hidden'); + } + else { + scrollable.removeStyle('overflow-y'); + } + }, null, null, 10000); + } + }); + } + return !!editor; + }, + + /** + * Attaches an inline editor to a DOM element. + * + * @param {HTMLElement} element + * The element to attach the editor to. + * @param {object} format + * The text format used in the editor. + * @param {string} [mainToolbarId] + * The id attribute for the main editor toolbar, if any. + * @param {string} [floatedToolbarId] + * The id attribute for the floated editor toolbar, if any. + * + * @return {bool} + * Whether the call to `CKEDITOR.replace()` created an editor or not. + */ + attachInlineEditor: function (element, format, mainToolbarId, floatedToolbarId) { + this._loadExternalPlugins(format); + // Also pass settings that are Drupal-specific. + format.editorSettings.drupal = { + format: format.format + }; + + var settings = $.extend(true, {}, format.editorSettings); + + // If a toolbar is already provided for "true WYSIWYG" (in-place editing), + // then use that toolbar instead: override the default settings to render + // CKEditor UI's top toolbar into mainToolbar, and don't render the bottom + // toolbar at all. (CKEditor doesn't need a floated toolbar.) + if (mainToolbarId) { + var settingsOverride = { + extraPlugins: 'sharedspace', + removePlugins: 'floatingspace,elementspath', + sharedSpaces: { + top: mainToolbarId + } + }; + + // Find the "Source" button, if any, and replace it with "Sourcedialog". + // (The 'sourcearea' plugin only works in CKEditor's iframe mode.) + var sourceButtonFound = false; + for (var i = 0; !sourceButtonFound && i < settings.toolbar.length; i++) { + if (settings.toolbar[i] !== '/') { + for (var j = 0; !sourceButtonFound && j < settings.toolbar[i].items.length; j++) { + if (settings.toolbar[i].items[j] === 'Source') { + sourceButtonFound = true; + // Swap sourcearea's "Source" button for sourcedialog's. + settings.toolbar[i].items[j] = 'Sourcedialog'; + settingsOverride.extraPlugins += ',sourcedialog'; + settingsOverride.removePlugins += ',sourcearea'; + } + } + } + } + + settings.extraPlugins += ',' + settingsOverride.extraPlugins; + settings.removePlugins += ',' + settingsOverride.removePlugins; + settings.sharedSpaces = settingsOverride.sharedSpaces; + } + + // CKEditor requires an element to already have the contentEditable + // attribute set to "true", otherwise it won't attach an inline editor. + element.setAttribute('contentEditable', 'true'); + + return !!CKEDITOR.inline(element, settings); + }, + + /** + * Loads the required external plugins for the editor. + * + * @param {object} format + * The text format used in the editor. + */ + _loadExternalPlugins: function (format) { + var externalPlugins = format.editorSettings.drupalExternalPlugins; + // Register and load additional CKEditor plugins as necessary. + if (externalPlugins) { + for (var pluginName in externalPlugins) { + if (externalPlugins.hasOwnProperty(pluginName)) { + CKEDITOR.plugins.addExternal(pluginName, externalPlugins[pluginName], ''); + } + } + delete format.editorSettings.drupalExternalPlugins; + } + } + + }; + + Drupal.ckeditor = { + + /** + * Variable storing the current dialog's save callback. + * + * @type {?function} + */ + saveCallback: null, + + /** + * Open a dialog for a Drupal-based plugin. + * + * This dynamically loads jQuery UI (if necessary) using the Drupal AJAX + * framework, then opens a dialog at the specified Drupal path. + * + * @param {CKEditor} editor + * The CKEditor instance that is opening the dialog. + * @param {string} url + * The URL that contains the contents of the dialog. + * @param {object} existingValues + * Existing values that will be sent via POST to the url for the dialog + * contents. + * @param {function} saveCallback + * A function to be called upon saving the dialog. + * @param {object} dialogSettings + * An object containing settings to be passed to the jQuery UI. + */ + openDialog: function (editor, url, existingValues, saveCallback, dialogSettings) { + // Locate a suitable place to display our loading indicator. + var $target = $(editor.container.$); + if (editor.elementMode === CKEDITOR.ELEMENT_MODE_REPLACE) { + $target = $target.find('.cke_contents'); + } + + // Remove any previous loading indicator. + $target.css('position', 'relative').find('.ckeditor-dialog-loading').remove(); + + // Add a consistent dialog class. + var classes = dialogSettings.dialogClass ? dialogSettings.dialogClass.split(' ') : []; + classes.push('ui-dialog--narrow'); + dialogSettings.dialogClass = classes.join(' '); + dialogSettings.autoResize = window.matchMedia('(min-width: 600px)').matches; + dialogSettings.width = 'auto'; + + // Add a "Loading…" message, hide it underneath the CKEditor toolbar, + // create a Drupal.Ajax instance to load the dialog and trigger it. + var $content = $('<div class="ckeditor-dialog-loading"><span style="top: -40px;" class="ckeditor-dialog-loading-link">' + Drupal.t('Loading...') + '</span></div>'); + $content.appendTo($target); + + var ckeditorAjaxDialog = Drupal.ajax({ + dialog: dialogSettings, + dialogType: 'modal', + selector: '.ckeditor-dialog-loading-link', + url: url, + progress: {type: 'throbber'}, + submit: { + editor_object: existingValues + } + }); + ckeditorAjaxDialog.execute(); + + // After a short delay, show "Loading…" message. + window.setTimeout(function () { + $content.find('span').animate({top: '0px'}); + }, 1000); + + // Store the save callback to be executed when this dialog is closed. + Drupal.ckeditor.saveCallback = saveCallback; + } + }; + + // Moves the dialog to the top of the CKEDITOR stack. + $(window).on('dialogcreate', function (e, dialog, $element, settings) { + $('.ui-dialog--narrow').css('zIndex', CKEDITOR.config.baseFloatZIndex + 1); + }); + + // Respond to new dialogs that are opened by CKEditor, closing the AJAX loader. + $(window).on('dialog:beforecreate', function (e, dialog, $element, settings) { + $('.ckeditor-dialog-loading').animate({top: '-40px'}, function () { + $(this).remove(); + }); + }); + + // Respond to dialogs that are saved, sending data back to CKEditor. + $(window).on('editor:dialogsave', function (e, values) { + if (Drupal.ckeditor.saveCallback) { + Drupal.ckeditor.saveCallback(values); + } + }); + + // Respond to dialogs that are closed, removing the current save handler. + $(window).on('dialog:afterclose', function (e, dialog, $element) { + if (Drupal.ckeditor.saveCallback) { + Drupal.ckeditor.saveCallback = null; + } + }); + + // Formulate a default formula for the maximum autoGrow height. + $(document).on('drupalViewportOffsetChange', function () { + CKEDITOR.config.autoGrow_maxHeight = 0.7 * (window.innerHeight - displace.offsets.top - displace.offsets.bottom); + }); + + // Redirect on hash change when the original hash has an associated CKEditor. + function redirectTextareaFragmentToCKEditorInstance() { + var hash = location.hash.substr(1); + var element = document.getElementById(hash); + if (element) { + var editor = CKEDITOR.dom.element.get(element).getEditor(); + if (editor) { + var id = editor.container.getAttribute('id'); + location.replace('#' + id); + } + } + } + $(window).on('hashchange.ckeditor', redirectTextareaFragmentToCKEditorInstance); + + // Set autoGrow to make the editor grow the moment it is created. + CKEDITOR.config.autoGrow_onStartup = true; + + // Set the CKEditor cache-busting string to the same value as Drupal. + CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp; + + if (AjaxCommands) { + + /** + * Command to add style sheets to a CKEditor instance. + * + * Works for both iframe and inline CKEditor instances. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.editor_id + * The CKEditor instance ID. + * @param {number} [status] + * The XMLHttpRequest status. + * + * @see http://docs.ckeditor.com/#!/api/CKEDITOR.dom.document + */ + AjaxCommands.prototype.ckeditor_add_stylesheet = function (ajax, response, status) { + var editor = CKEDITOR.instances[response.editor_id]; + + if (editor) { + response.stylesheets.forEach(function (url) { + editor.document.appendStyleSheet(url); + }); + } + }; + } + +})(Drupal, Drupal.debounce, CKEDITOR, jQuery, Drupal.displace, Drupal.AjaxCommands); diff --git a/core/modules/ckeditor/js/ckeditor.js b/core/modules/ckeditor/js/ckeditor.js index 2b469c8037c7..bf130cfd6241 100644 --- a/core/modules/ckeditor/js/ckeditor.js +++ b/core/modules/ckeditor/js/ckeditor.js @@ -1,65 +1,35 @@ /** - * @file - * CKEditor implementation of {@link Drupal.editors} API. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/ckeditor.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, debounce, CKEDITOR, $, displace, AjaxCommands) { 'use strict'; - /** - * @namespace - */ Drupal.editors.ckeditor = { - - /** - * Editor attach callback. - * - * @param {HTMLElement} element - * The element to attach the editor to. - * @param {string} format - * The text format for the editor. - * - * @return {bool} - * Whether the call to `CKEDITOR.replace()` created an editor or not. - */ - attach: function (element, format) { + attach: function attach(element, format) { this._loadExternalPlugins(format); - // Also pass settings that are Drupal-specific. + format.editorSettings.drupal = { format: format.format }; - // Set a title on the CKEditor instance that includes the text field's - // label so that screen readers say something that is understandable - // for end users. var label = $('label[for=' + element.getAttribute('id') + ']').html(); - format.editorSettings.title = Drupal.t('Rich Text Editor, !label field', {'!label': label}); + format.editorSettings.title = Drupal.t('Rich Text Editor, !label field', { '!label': label }); return !!CKEDITOR.replace(element, format.editorSettings); }, - /** - * Editor detach callback. - * - * @param {HTMLElement} element - * The element to detach the editor from. - * @param {string} format - * The text format used for the editor. - * @param {string} trigger - * The event trigger for the detach. - * - * @return {bool} - * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()` - * found an editor or not. - */ - detach: function (element, format, trigger) { + detach: function detach(element, format, trigger) { var editor = CKEDITOR.dom.element.get(element).getEditor(); if (editor) { if (trigger === 'serialize') { editor.updateElement(); - } - else { + } else { editor.destroy(); element.removeAttribute('contentEditable'); } @@ -67,28 +37,13 @@ return !!editor; }, - /** - * Reacts on a change in the editor element. - * - * @param {HTMLElement} element - * The element where the change occured. - * @param {function} callback - * Callback called with the value of the editor. - * - * @return {bool} - * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()` - * found an editor or not. - */ - onChange: function (element, callback) { + onChange: function onChange(element, callback) { var editor = CKEDITOR.dom.element.get(element).getEditor(); if (editor) { editor.on('change', debounce(function () { callback(editor.getData()); }, 400)); - // A temporary workaround to control scrollbar appearance when using - // autoGrow event to control editor's height. - // @todo Remove when http://dev.ckeditor.com/ticket/12120 is fixed. editor.on('mode', function () { var editable = editor.editable(); if (!editable.isInline()) { @@ -98,8 +53,7 @@ if (scrollable.$.scrollHeight < scrollable.$.clientHeight) { scrollable.setStyle('overflow-y', 'hidden'); - } - else { + } else { scrollable.removeStyle('overflow-y'); } }, null, null, 10000); @@ -109,34 +63,15 @@ return !!editor; }, - /** - * Attaches an inline editor to a DOM element. - * - * @param {HTMLElement} element - * The element to attach the editor to. - * @param {object} format - * The text format used in the editor. - * @param {string} [mainToolbarId] - * The id attribute for the main editor toolbar, if any. - * @param {string} [floatedToolbarId] - * The id attribute for the floated editor toolbar, if any. - * - * @return {bool} - * Whether the call to `CKEDITOR.replace()` created an editor or not. - */ - attachInlineEditor: function (element, format, mainToolbarId, floatedToolbarId) { + attachInlineEditor: function attachInlineEditor(element, format, mainToolbarId, floatedToolbarId) { this._loadExternalPlugins(format); - // Also pass settings that are Drupal-specific. + format.editorSettings.drupal = { format: format.format }; var settings = $.extend(true, {}, format.editorSettings); - // If a toolbar is already provided for "true WYSIWYG" (in-place editing), - // then use that toolbar instead: override the default settings to render - // CKEditor UI's top toolbar into mainToolbar, and don't render the bottom - // toolbar at all. (CKEditor doesn't need a floated toolbar.) if (mainToolbarId) { var settingsOverride = { extraPlugins: 'sharedspace', @@ -146,15 +81,13 @@ } }; - // Find the "Source" button, if any, and replace it with "Sourcedialog". - // (The 'sourcearea' plugin only works in CKEditor's iframe mode.) var sourceButtonFound = false; for (var i = 0; !sourceButtonFound && i < settings.toolbar.length; i++) { if (settings.toolbar[i] !== '/') { for (var j = 0; !sourceButtonFound && j < settings.toolbar[i].items.length; j++) { if (settings.toolbar[i].items[j] === 'Source') { sourceButtonFound = true; - // Swap sourcearea's "Source" button for sourcedialog's. + settings.toolbar[i].items[j] = 'Sourcedialog'; settingsOverride.extraPlugins += ',sourcedialog'; settingsOverride.removePlugins += ',sourcearea'; @@ -168,22 +101,14 @@ settings.sharedSpaces = settingsOverride.sharedSpaces; } - // CKEditor requires an element to already have the contentEditable - // attribute set to "true", otherwise it won't attach an inline editor. element.setAttribute('contentEditable', 'true'); return !!CKEDITOR.inline(element, settings); }, - /** - * Loads the required external plugins for the editor. - * - * @param {object} format - * The text format used in the editor. - */ - _loadExternalPlugins: function (format) { + _loadExternalPlugins: function _loadExternalPlugins(format) { var externalPlugins = format.editorSettings.drupalExternalPlugins; - // Register and load additional CKEditor plugins as necessary. + if (externalPlugins) { for (var pluginName in externalPlugins) { if (externalPlugins.hasOwnProperty(pluginName)) { @@ -197,51 +122,22 @@ }; Drupal.ckeditor = { - - /** - * Variable storing the current dialog's save callback. - * - * @type {?function} - */ saveCallback: null, - /** - * Open a dialog for a Drupal-based plugin. - * - * This dynamically loads jQuery UI (if necessary) using the Drupal AJAX - * framework, then opens a dialog at the specified Drupal path. - * - * @param {CKEditor} editor - * The CKEditor instance that is opening the dialog. - * @param {string} url - * The URL that contains the contents of the dialog. - * @param {object} existingValues - * Existing values that will be sent via POST to the url for the dialog - * contents. - * @param {function} saveCallback - * A function to be called upon saving the dialog. - * @param {object} dialogSettings - * An object containing settings to be passed to the jQuery UI. - */ - openDialog: function (editor, url, existingValues, saveCallback, dialogSettings) { - // Locate a suitable place to display our loading indicator. + openDialog: function openDialog(editor, url, existingValues, saveCallback, dialogSettings) { var $target = $(editor.container.$); if (editor.elementMode === CKEDITOR.ELEMENT_MODE_REPLACE) { $target = $target.find('.cke_contents'); } - // Remove any previous loading indicator. $target.css('position', 'relative').find('.ckeditor-dialog-loading').remove(); - // Add a consistent dialog class. var classes = dialogSettings.dialogClass ? dialogSettings.dialogClass.split(' ') : []; classes.push('ui-dialog--narrow'); dialogSettings.dialogClass = classes.join(' '); dialogSettings.autoResize = window.matchMedia('(min-width: 600px)').matches; dialogSettings.width = 'auto'; - // Add a "Loading…" message, hide it underneath the CKEditor toolbar, - // create a Drupal.Ajax instance to load the dialog and trigger it. var $content = $('<div class="ckeditor-dialog-loading"><span style="top: -40px;" class="ckeditor-dialog-loading-link">' + Drupal.t('Loading...') + '</span></div>'); $content.appendTo($target); @@ -250,55 +146,47 @@ dialogType: 'modal', selector: '.ckeditor-dialog-loading-link', url: url, - progress: {type: 'throbber'}, + progress: { type: 'throbber' }, submit: { editor_object: existingValues } }); ckeditorAjaxDialog.execute(); - // After a short delay, show "Loading…" message. window.setTimeout(function () { - $content.find('span').animate({top: '0px'}); + $content.find('span').animate({ top: '0px' }); }, 1000); - // Store the save callback to be executed when this dialog is closed. Drupal.ckeditor.saveCallback = saveCallback; } }; - // Moves the dialog to the top of the CKEDITOR stack. $(window).on('dialogcreate', function (e, dialog, $element, settings) { $('.ui-dialog--narrow').css('zIndex', CKEDITOR.config.baseFloatZIndex + 1); }); - // Respond to new dialogs that are opened by CKEditor, closing the AJAX loader. $(window).on('dialog:beforecreate', function (e, dialog, $element, settings) { - $('.ckeditor-dialog-loading').animate({top: '-40px'}, function () { + $('.ckeditor-dialog-loading').animate({ top: '-40px' }, function () { $(this).remove(); }); }); - // Respond to dialogs that are saved, sending data back to CKEditor. $(window).on('editor:dialogsave', function (e, values) { if (Drupal.ckeditor.saveCallback) { Drupal.ckeditor.saveCallback(values); } }); - // Respond to dialogs that are closed, removing the current save handler. $(window).on('dialog:afterclose', function (e, dialog, $element) { if (Drupal.ckeditor.saveCallback) { Drupal.ckeditor.saveCallback = null; } }); - // Formulate a default formula for the maximum autoGrow height. $(document).on('drupalViewportOffsetChange', function () { CKEDITOR.config.autoGrow_maxHeight = 0.7 * (window.innerHeight - displace.offsets.top - displace.offsets.bottom); }); - // Redirect on hash change when the original hash has an associated CKEditor. function redirectTextareaFragmentToCKEditorInstance() { var hash = location.hash.substr(1); var element = document.getElementById(hash); @@ -312,30 +200,11 @@ } $(window).on('hashchange.ckeditor', redirectTextareaFragmentToCKEditorInstance); - // Set autoGrow to make the editor grow the moment it is created. CKEDITOR.config.autoGrow_onStartup = true; - // Set the CKEditor cache-busting string to the same value as Drupal. CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp; if (AjaxCommands) { - - /** - * Command to add style sheets to a CKEditor instance. - * - * Works for both iframe and inline CKEditor instances. - * - * @param {Drupal.Ajax} [ajax] - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * The response from the Ajax request. - * @param {string} response.editor_id - * The CKEditor instance ID. - * @param {number} [status] - * The XMLHttpRequest status. - * - * @see http://docs.ckeditor.com/#!/api/CKEDITOR.dom.document - */ AjaxCommands.prototype.ckeditor_add_stylesheet = function (ajax, response, status) { var editor = CKEDITOR.instances[response.editor_id]; @@ -346,5 +215,4 @@ } }; } - -})(Drupal, Drupal.debounce, CKEDITOR, jQuery, Drupal.displace, Drupal.AjaxCommands); +})(Drupal, Drupal.debounce, CKEDITOR, jQuery, Drupal.displace, Drupal.AjaxCommands); \ No newline at end of file diff --git a/core/modules/ckeditor/js/ckeditor.language.admin.es6.js b/core/modules/ckeditor/js/ckeditor.language.admin.es6.js new file mode 100644 index 000000000000..1b9b5a66f07e --- /dev/null +++ b/core/modules/ckeditor/js/ckeditor.language.admin.es6.js @@ -0,0 +1,16 @@ +(function ($, Drupal) { + + 'use strict'; + + /** + * Provides the summary for the "language" plugin settings vertical tab. + */ + Drupal.behaviors.ckeditorLanguageSettingsSummary = { + attach: function () { + $('#edit-editor-settings-plugins-language').drupalSetSummary(function (context) { + return $('#edit-editor-settings-plugins-language-language-list-type option:selected').text(); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/ckeditor/js/ckeditor.language.admin.js b/core/modules/ckeditor/js/ckeditor.language.admin.js index 1b9b5a66f07e..7d7b70107aae 100644 --- a/core/modules/ckeditor/js/ckeditor.language.admin.js +++ b/core/modules/ckeditor/js/ckeditor.language.admin.js @@ -1,16 +1,20 @@ +/** +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/ckeditor.language.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + (function ($, Drupal) { 'use strict'; - /** - * Provides the summary for the "language" plugin settings vertical tab. - */ Drupal.behaviors.ckeditorLanguageSettingsSummary = { - attach: function () { + attach: function attach() { $('#edit-editor-settings-plugins-language').drupalSetSummary(function (context) { return $('#edit-editor-settings-plugins-language-language-list-type option:selected').text(); }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/ckeditor/js/ckeditor.stylescombo.admin.es6.js b/core/modules/ckeditor/js/ckeditor.stylescombo.admin.es6.js new file mode 100644 index 000000000000..c425dbb30028 --- /dev/null +++ b/core/modules/ckeditor/js/ckeditor.stylescombo.admin.es6.js @@ -0,0 +1,128 @@ +/** + * @file + * CKEditor StylesCombo admin behavior. + */ + +(function ($, Drupal, drupalSettings, _) { + + 'use strict'; + + /** + * Ensures that the "stylescombo" button's metadata remains up-to-date. + * + * Triggers the CKEditorPluginSettingsChanged event whenever the "stylescombo" + * plugin settings change, to ensure that the corresponding feature metadata + * is immediately updated — i.e. ensure that HTML tags and classes entered + * here are known to be "required", which may affect filter settings. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches admin behaviour to the "stylescombo" button. + */ + Drupal.behaviors.ckeditorStylesComboSettings = { + attach: function (context) { + var $context = $(context); + + // React to changes in the list of user-defined styles: calculate the new + // stylesSet setting up to 2 times per second, and if it is different, + // fire the CKEditorPluginSettingsChanged event with the updated parts of + // the CKEditor configuration. (This will, in turn, cause the hidden + // CKEditor instance to be updated and a drupalEditorFeatureModified event + // to fire.) + var $ckeditorActiveToolbar = $context + .find('.ckeditor-toolbar-configuration') + .find('.ckeditor-toolbar-active'); + var previousStylesSet = drupalSettings.ckeditor.hiddenCKEditorConfig.stylesSet; + var that = this; + $context.find('[name="editor[settings][plugins][stylescombo][styles]"]') + .on('blur.ckeditorStylesComboSettings', function () { + var styles = $.trim($(this).val()); + var stylesSet = that._generateStylesSetSetting(styles); + if (!_.isEqual(previousStylesSet, stylesSet)) { + previousStylesSet = stylesSet; + $ckeditorActiveToolbar.trigger('CKEditorPluginSettingsChanged', [ + {stylesSet: stylesSet} + ]); + } + }); + }, + + /** + * Builds the "stylesSet" configuration part of the CKEditor JS settings. + * + * @see \Drupal\ckeditor\Plugin\ckeditor\plugin\StylesCombo::generateStylesSetSetting() + * + * Note that this is a more forgiving implementation than the PHP version: + * the parsing works identically, but instead of failing on invalid styles, + * we just ignore those. + * + * @param {string} styles + * The "styles" setting. + * + * @return {Array} + * An array containing the "stylesSet" configuration. + */ + _generateStylesSetSetting: function (styles) { + var stylesSet = []; + + styles = styles.replace(/\r/g, '\n'); + var lines = styles.split('\n'); + for (var i = 0; i < lines.length; i++) { + var style = $.trim(lines[i]); + + // Ignore empty lines in between non-empty lines. + if (style.length === 0) { + continue; + } + + // Validate syntax: element[.class...]|label pattern expected. + if (style.match(/^ *[a-zA-Z0-9]+ *(\.[a-zA-Z0-9_-]+ *)*\| *.+ *$/) === null) { + // Instead of failing, we just ignore any invalid styles. + continue; + } + + // Parse. + var parts = style.split('|'); + var selector = parts[0]; + var label = parts[1]; + var classes = selector.split('.'); + var element = classes.shift(); + + // Build the data structure CKEditor's stylescombo plugin expects. + // @see http://docs.cksource.com/CKEditor_3.x/Developers_Guide/Styles + stylesSet.push({ + attributes: {class: classes.join(' ')}, + element: element, + name: label + }); + } + + return stylesSet; + } + }; + + /** + * Provides the summary for the "stylescombo" plugin settings vertical tab. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behaviour to the plugin settings vertical tab. + */ + Drupal.behaviors.ckeditorStylesComboSettingsSummary = { + attach: function () { + $('[data-ckeditor-plugin-id="stylescombo"]').drupalSetSummary(function (context) { + var styles = $.trim($('[data-drupal-selector="edit-editor-settings-plugins-stylescombo-styles"]').val()); + if (styles.length === 0) { + return Drupal.t('No styles configured'); + } + else { + var count = $.trim(styles).split('\n').length; + return Drupal.t('@count styles configured', {'@count': count}); + } + }); + } + }; + +})(jQuery, Drupal, drupalSettings, _); diff --git a/core/modules/ckeditor/js/ckeditor.stylescombo.admin.js b/core/modules/ckeditor/js/ckeditor.stylescombo.admin.js index c425dbb30028..f90dfb6cf623 100644 --- a/core/modules/ckeditor/js/ckeditor.stylescombo.admin.js +++ b/core/modules/ckeditor/js/ckeditor.stylescombo.admin.js @@ -1,69 +1,33 @@ /** - * @file - * CKEditor StylesCombo admin behavior. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/ckeditor.stylescombo.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings, _) { 'use strict'; - /** - * Ensures that the "stylescombo" button's metadata remains up-to-date. - * - * Triggers the CKEditorPluginSettingsChanged event whenever the "stylescombo" - * plugin settings change, to ensure that the corresponding feature metadata - * is immediately updated — i.e. ensure that HTML tags and classes entered - * here are known to be "required", which may affect filter settings. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches admin behaviour to the "stylescombo" button. - */ Drupal.behaviors.ckeditorStylesComboSettings = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); - // React to changes in the list of user-defined styles: calculate the new - // stylesSet setting up to 2 times per second, and if it is different, - // fire the CKEditorPluginSettingsChanged event with the updated parts of - // the CKEditor configuration. (This will, in turn, cause the hidden - // CKEditor instance to be updated and a drupalEditorFeatureModified event - // to fire.) - var $ckeditorActiveToolbar = $context - .find('.ckeditor-toolbar-configuration') - .find('.ckeditor-toolbar-active'); + var $ckeditorActiveToolbar = $context.find('.ckeditor-toolbar-configuration').find('.ckeditor-toolbar-active'); var previousStylesSet = drupalSettings.ckeditor.hiddenCKEditorConfig.stylesSet; var that = this; - $context.find('[name="editor[settings][plugins][stylescombo][styles]"]') - .on('blur.ckeditorStylesComboSettings', function () { - var styles = $.trim($(this).val()); - var stylesSet = that._generateStylesSetSetting(styles); - if (!_.isEqual(previousStylesSet, stylesSet)) { - previousStylesSet = stylesSet; - $ckeditorActiveToolbar.trigger('CKEditorPluginSettingsChanged', [ - {stylesSet: stylesSet} - ]); - } - }); + $context.find('[name="editor[settings][plugins][stylescombo][styles]"]').on('blur.ckeditorStylesComboSettings', function () { + var styles = $.trim($(this).val()); + var stylesSet = that._generateStylesSetSetting(styles); + if (!_.isEqual(previousStylesSet, stylesSet)) { + previousStylesSet = stylesSet; + $ckeditorActiveToolbar.trigger('CKEditorPluginSettingsChanged', [{ stylesSet: stylesSet }]); + } + }); }, - /** - * Builds the "stylesSet" configuration part of the CKEditor JS settings. - * - * @see \Drupal\ckeditor\Plugin\ckeditor\plugin\StylesCombo::generateStylesSetSetting() - * - * Note that this is a more forgiving implementation than the PHP version: - * the parsing works identically, but instead of failing on invalid styles, - * we just ignore those. - * - * @param {string} styles - * The "styles" setting. - * - * @return {Array} - * An array containing the "stylesSet" configuration. - */ - _generateStylesSetSetting: function (styles) { + _generateStylesSetSetting: function _generateStylesSetSetting(styles) { var stylesSet = []; styles = styles.replace(/\r/g, '\n'); @@ -71,28 +35,22 @@ for (var i = 0; i < lines.length; i++) { var style = $.trim(lines[i]); - // Ignore empty lines in between non-empty lines. if (style.length === 0) { continue; } - // Validate syntax: element[.class...]|label pattern expected. if (style.match(/^ *[a-zA-Z0-9]+ *(\.[a-zA-Z0-9_-]+ *)*\| *.+ *$/) === null) { - // Instead of failing, we just ignore any invalid styles. continue; } - // Parse. var parts = style.split('|'); var selector = parts[0]; var label = parts[1]; var classes = selector.split('.'); var element = classes.shift(); - // Build the data structure CKEditor's stylescombo plugin expects. - // @see http://docs.cksource.com/CKEditor_3.x/Developers_Guide/Styles stylesSet.push({ - attributes: {class: classes.join(' ')}, + attributes: { class: classes.join(' ') }, element: element, name: label }); @@ -102,27 +60,17 @@ } }; - /** - * Provides the summary for the "stylescombo" plugin settings vertical tab. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behaviour to the plugin settings vertical tab. - */ Drupal.behaviors.ckeditorStylesComboSettingsSummary = { - attach: function () { + attach: function attach() { $('[data-ckeditor-plugin-id="stylescombo"]').drupalSetSummary(function (context) { var styles = $.trim($('[data-drupal-selector="edit-editor-settings-plugins-stylescombo-styles"]').val()); if (styles.length === 0) { return Drupal.t('No styles configured'); - } - else { + } else { var count = $.trim(styles).split('\n').length; - return Drupal.t('@count styles configured', {'@count': count}); + return Drupal.t('@count styles configured', { '@count': count }); } }); } }; - -})(jQuery, Drupal, drupalSettings, _); +})(jQuery, Drupal, drupalSettings, _); \ No newline at end of file diff --git a/core/modules/ckeditor/js/models/Model.es6.js b/core/modules/ckeditor/js/models/Model.es6.js new file mode 100644 index 000000000000..162e363eadf5 --- /dev/null +++ b/core/modules/ckeditor/js/models/Model.es6.js @@ -0,0 +1,75 @@ +/** + * @file + * A Backbone Model for the state of a CKEditor toolbar configuration . + */ + +(function (Drupal, Backbone) { + + 'use strict'; + + /** + * Backbone model for the CKEditor toolbar configuration state. + * + * @constructor + * + * @augments Backbone.Model + */ + Drupal.ckeditor.Model = Backbone.Model.extend(/** @lends Drupal.ckeditor.Model# */{ + + /** + * Default values. + * + * @type {object} + */ + defaults: /** @lends Drupal.ckeditor.Model# */{ + + /** + * The CKEditor configuration that is being manipulated through the UI. + */ + activeEditorConfig: null, + + /** + * The textarea that contains the serialized representation of the active + * CKEditor configuration. + */ + $textarea: null, + + /** + * Tracks whether the active toolbar DOM structure has been changed. When + * true, activeEditorConfig needs to be updated, and when that is updated, + * $textarea will also be updated. + */ + isDirty: false, + + /** + * The configuration for the hidden CKEditor instance that is used to + * build the features metadata. + */ + hiddenEditorConfig: null, + + /** + * A hash that maps buttons to features. + */ + buttonsToFeatures: null, + + /** + * A hash, keyed by a feature name, that details CKEditor plugin features. + */ + featuresMetadata: null, + + /** + * Whether the button group names are currently visible. + */ + groupNamesVisible: false + }, + + /** + * @method + */ + sync: function () { + // Push the settings into the textarea. + this.get('$textarea').val(JSON.stringify(this.get('activeEditorConfig'))); + } + }); + +})(Drupal, Backbone); diff --git a/core/modules/ckeditor/js/models/Model.js b/core/modules/ckeditor/js/models/Model.js index 162e363eadf5..c5cff3245bdc 100644 --- a/core/modules/ckeditor/js/models/Model.js +++ b/core/modules/ckeditor/js/models/Model.js @@ -1,75 +1,34 @@ /** - * @file - * A Backbone Model for the state of a CKEditor toolbar configuration . - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/models/Model.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone) { 'use strict'; - /** - * Backbone model for the CKEditor toolbar configuration state. - * - * @constructor - * - * @augments Backbone.Model - */ - Drupal.ckeditor.Model = Backbone.Model.extend(/** @lends Drupal.ckeditor.Model# */{ - - /** - * Default values. - * - * @type {object} - */ - defaults: /** @lends Drupal.ckeditor.Model# */{ - - /** - * The CKEditor configuration that is being manipulated through the UI. - */ + Drupal.ckeditor.Model = Backbone.Model.extend({ + defaults: { activeEditorConfig: null, - /** - * The textarea that contains the serialized representation of the active - * CKEditor configuration. - */ $textarea: null, - /** - * Tracks whether the active toolbar DOM structure has been changed. When - * true, activeEditorConfig needs to be updated, and when that is updated, - * $textarea will also be updated. - */ isDirty: false, - /** - * The configuration for the hidden CKEditor instance that is used to - * build the features metadata. - */ hiddenEditorConfig: null, - /** - * A hash that maps buttons to features. - */ buttonsToFeatures: null, - /** - * A hash, keyed by a feature name, that details CKEditor plugin features. - */ featuresMetadata: null, - /** - * Whether the button group names are currently visible. - */ groupNamesVisible: false }, - /** - * @method - */ - sync: function () { - // Push the settings into the textarea. + sync: function sync() { this.get('$textarea').val(JSON.stringify(this.get('activeEditorConfig'))); } }); - -})(Drupal, Backbone); +})(Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/ckeditor/js/plugins/drupalimage/plugin.es6.js b/core/modules/ckeditor/js/plugins/drupalimage/plugin.es6.js new file mode 100644 index 000000000000..0025664e09f7 --- /dev/null +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.es6.js @@ -0,0 +1,371 @@ +/** + * @file + * Drupal Image plugin. + * + * This alters the existing CKEditor image2 widget plugin to: + * - require a data-entity-type and a data-entity-uuid attribute (which Drupal + * uses to track where images are being used) + * - use a Drupal-native dialog (that is in fact just an alterable Drupal form + * like any other) instead of CKEditor's own dialogs. + * + * @see \Drupal\editor\Form\EditorImageDialog + * + * @ignore + */ + +(function ($, Drupal, CKEDITOR) { + + 'use strict'; + + CKEDITOR.plugins.add('drupalimage', { + requires: 'image2', + icons: 'drupalimage', + hidpi: true, + + beforeInit: function (editor) { + // Override the image2 widget definition to require and handle the + // additional data-entity-type and data-entity-uuid attributes. + editor.on('widgetDefinition', function (event) { + var widgetDefinition = event.data; + if (widgetDefinition.name !== 'image') { + return; + } + + // First, convert requiredContent & allowedContent from the string + // format that image2 uses for both to formats that are better suited + // for extending, so that both this basic drupalimage plugin and Drupal + // modules can easily extend it. + // @see http://docs.ckeditor.com/#!/api/CKEDITOR.filter.allowedContentRules + // Mapped from image2's allowedContent. Unlike image2, we don't allow + // <figure>, <figcaption>, <div> or <p> in our downcast, so we omit + // those. For the <img> tag, we list all attributes it lists, but omit + // the classes, because the listed classes are for alignment, and for + // alignment we use the data-align attribute. + widgetDefinition.allowedContent = { + img: { + attributes: { + '!src': true, + '!alt': true, + 'width': true, + 'height': true + }, + classes: {} + } + }; + // Mapped from image2's requiredContent: "img[src,alt]". This does not + // use the object format unlike above, but a CKEDITOR.style instance, + // because requiredContent does not support the object format. + // @see https://www.drupal.org/node/2585173#comment-10456981 + widgetDefinition.requiredContent = new CKEDITOR.style({ + element: 'img', + attributes: { + src: '', + alt: '' + } + }); + + // Extend requiredContent & allowedContent. + // CKEDITOR.style is an immutable object: we cannot modify its + // definition to extend requiredContent. Hence we get the definition, + // modify it, and pass it to a new CKEDITOR.style instance. + var requiredContent = widgetDefinition.requiredContent.getDefinition(); + requiredContent.attributes['data-entity-type'] = ''; + requiredContent.attributes['data-entity-uuid'] = ''; + widgetDefinition.requiredContent = new CKEDITOR.style(requiredContent); + widgetDefinition.allowedContent.img.attributes['!data-entity-type'] = true; + widgetDefinition.allowedContent.img.attributes['!data-entity-uuid'] = true; + + // Override downcast(): since we only accept <img> in our upcast method, + // the element is already correct. We only need to update the element's + // data-entity-uuid attribute. + widgetDefinition.downcast = function (element) { + element.attributes['data-entity-type'] = this.data['data-entity-type']; + element.attributes['data-entity-uuid'] = this.data['data-entity-uuid']; + }; + + // We want to upcast <img> elements to a DOM structure required by the + // image2 widget; we only accept an <img> tag, and that <img> tag MAY + // have a data-entity-type and a data-entity-uuid attribute. + widgetDefinition.upcast = function (element, data) { + if (element.name !== 'img') { + return; + } + // Don't initialize on pasted fake objects. + else if (element.attributes['data-cke-realelement']) { + return; + } + + // Parse the data-entity-type attribute. + data['data-entity-type'] = element.attributes['data-entity-type']; + // Parse the data-entity-uuid attribute. + data['data-entity-uuid'] = element.attributes['data-entity-uuid']; + + return element; + }; + + // Overrides default implementation. Used to populate the "classes" + // property of the widget's "data" property, which is used for the + // "widget styles" functionality + // (http://docs.ckeditor.com/#!/guide/dev_styles-section-widget-styles). + // Is applied to whatever the main element of the widget is (<figure> or + // <img>). The classes in image2_captionedClass are always added due to + // a bug in CKEditor. In the case of drupalimage, we don't ever want to + // add that class, because the widget template already contains it. + // @see http://dev.ckeditor.com/ticket/13888 + // @see https://www.drupal.org/node/2268941 + var originalGetClasses = widgetDefinition.getClasses; + widgetDefinition.getClasses = function () { + var classes = originalGetClasses.call(this); + var captionedClasses = (this.editor.config.image2_captionedClass || '').split(/\s+/); + + if (captionedClasses.length && classes) { + for (var i = 0; i < captionedClasses.length; i++) { + if (captionedClasses[i] in classes) { + delete classes[captionedClasses[i]]; + } + } + } + + return classes; + }; + + // Protected; keys of the widget data to be sent to the Drupal dialog. + // Keys in the hash are the keys for image2's data, values are the keys + // that the Drupal dialog uses. + widgetDefinition._mapDataToDialog = { + 'src': 'src', + 'alt': 'alt', + 'width': 'width', + 'height': 'height', + 'data-entity-type': 'data-entity-type', + 'data-entity-uuid': 'data-entity-uuid' + }; + + // Protected; transforms widget's data object to the format used by the + // \Drupal\editor\Form\EditorImageDialog dialog, keeping only the data + // listed in widgetDefinition._dataForDialog. + widgetDefinition._dataToDialogValues = function (data) { + var dialogValues = {}; + var map = widgetDefinition._mapDataToDialog; + Object.keys(widgetDefinition._mapDataToDialog).forEach(function (key) { + dialogValues[map[key]] = data[key]; + }); + return dialogValues; + }; + + // Protected; the inverse of _dataToDialogValues. + widgetDefinition._dialogValuesToData = function (dialogReturnValues) { + var data = {}; + var map = widgetDefinition._mapDataToDialog; + Object.keys(widgetDefinition._mapDataToDialog).forEach(function (key) { + if (dialogReturnValues.hasOwnProperty(map[key])) { + data[key] = dialogReturnValues[map[key]]; + } + }); + return data; + }; + + // Protected; creates Drupal dialog save callback. + widgetDefinition._createDialogSaveCallback = function (editor, widget) { + return function (dialogReturnValues) { + var firstEdit = !widget.ready; + + // Dialog may have blurred the widget. Re-focus it first. + if (!firstEdit) { + widget.focus(); + } + + editor.fire('saveSnapshot'); + + // Pass `true` so DocumentFragment will also be returned. + var container = widget.wrapper.getParent(true); + var image = widget.parts.image; + + // Set the updated widget data, after the necessary conversions from + // the dialog's return values. + // Note: on widget#setData this widget instance might be destroyed. + var data = widgetDefinition._dialogValuesToData(dialogReturnValues.attributes); + widget.setData(data); + + // Retrieve the widget once again. It could've been destroyed + // when shifting state, so might deal with a new instance. + widget = editor.widgets.getByElement(image); + + // It's first edit, just after widget instance creation, but before + // it was inserted into DOM. So we need to retrieve the widget + // wrapper from inside the DocumentFragment which we cached above + // and finalize other things (like ready event and flag). + if (firstEdit) { + editor.widgets.finalizeCreation(container); + } + + setTimeout(function () { + // (Re-)focus the widget. + widget.focus(); + // Save snapshot for undo support. + editor.fire('saveSnapshot'); + }); + + return widget; + }; + }; + + var originalInit = widgetDefinition.init; + widgetDefinition.init = function () { + originalInit.call(this); + + // Update data.link object with attributes if the link has been + // discovered. + // @see plugins/image2/plugin.js/init() in CKEditor; this is similar. + if (this.parts.link) { + this.setData('link', CKEDITOR.plugins.image2.getLinkAttributesParser()(editor, this.parts.link)); + } + }; + }); + + // Add a widget#edit listener to every instance of image2 widget in order + // to handle its editing with a Drupal-native dialog. + // This includes also a case just after the image was created + // and dialog should be opened for it for the first time. + editor.widgets.on('instanceCreated', function (event) { + var widget = event.data; + + if (widget.name !== 'image') { + return; + } + + widget.on('edit', function (event) { + // Cancel edit event to break image2's dialog binding + // (and also to prevent automatic insertion before opening dialog). + event.cancel(); + + // Open drupalimage dialog. + editor.execCommand('editdrupalimage', { + existingValues: widget.definition._dataToDialogValues(widget.data), + saveCallback: widget.definition._createDialogSaveCallback(editor, widget), + // Drupal.t() will not work inside CKEditor plugins because CKEditor + // loads the JavaScript file instead of Drupal. Pull translated + // strings from the plugin settings that are translated server-side. + dialogTitle: widget.data.src ? editor.config.drupalImage_dialogTitleEdit : editor.config.drupalImage_dialogTitleAdd + }); + }); + }); + + // Register the "editdrupalimage" command, which essentially just replaces + // the "image" command's CKEditor dialog with a Drupal-native dialog. + editor.addCommand('editdrupalimage', { + allowedContent: 'img[alt,!src,width,height,!data-entity-type,!data-entity-uuid]', + requiredContent: 'img[alt,src,data-entity-type,data-entity-uuid]', + modes: {wysiwyg: 1}, + canUndo: true, + exec: function (editor, data) { + var dialogSettings = { + title: data.dialogTitle, + dialogClass: 'editor-image-dialog' + }; + Drupal.ckeditor.openDialog(editor, Drupal.url('editor/dialog/image/' + editor.config.drupal.format), data.existingValues, data.saveCallback, dialogSettings); + } + }); + + // Register the toolbar button. + if (editor.ui.addButton) { + editor.ui.addButton('DrupalImage', { + label: Drupal.t('Image'), + // Note that we use the original image2 command! + command: 'image' + }); + } + }, + + afterInit: function (editor) { + linkCommandIntegrator(editor); + } + + }); + + // Override image2's integration with the official CKEditor link plugin: + // integrate with the drupallink plugin instead. + CKEDITOR.plugins.image2.getLinkAttributesParser = function () { + return CKEDITOR.plugins.drupallink.parseLinkAttributes; + }; + CKEDITOR.plugins.image2.getLinkAttributesGetter = function () { + return CKEDITOR.plugins.drupallink.getLinkAttributes; + }; + + /** + * Integrates the drupalimage widget with the drupallink plugin. + * + * Makes images linkable. + * + * @param {CKEDITOR.editor} editor + * A CKEditor instance. + */ + function linkCommandIntegrator(editor) { + // Nothing to integrate with if the drupallink plugin is not loaded. + if (!editor.plugins.drupallink) { + return; + } + + // Override default behaviour of 'drupalunlink' command. + editor.getCommand('drupalunlink').on('exec', function (evt) { + var widget = getFocusedWidget(editor); + + // Override 'drupalunlink' only when link truly belongs to the widget. If + // wrapped inline widget in a link, let default unlink work. + // @see https://dev.ckeditor.com/ticket/11814 + if (!widget || !widget.parts.link) { + return; + } + + widget.setData('link', null); + + // Selection (which is fake) may not change if unlinked image in focused + // widget, i.e. if captioned image. Let's refresh command state manually + // here. + this.refresh(editor, editor.elementPath()); + + evt.cancel(); + }); + + // Override default refresh of 'drupalunlink' command. + editor.getCommand('drupalunlink').on('refresh', function (evt) { + var widget = getFocusedWidget(editor); + + if (!widget) { + return; + } + + // Note that widget may be wrapped in a link, which + // does not belong to that widget (#11814). + this.setState(widget.data.link || widget.wrapper.getAscendant('a') ? + CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED); + + evt.cancel(); + }); + } + + /** + * Gets the focused widget, if of the type specific for this plugin. + * + * @param {CKEDITOR.editor} editor + * A CKEditor instance. + * + * @return {?CKEDITOR.plugins.widget} + * The focused image2 widget instance, or null. + */ + function getFocusedWidget(editor) { + var widget = editor.widgets.focused; + + if (widget && widget.name === 'image') { + return widget; + } + + return null; + } + + // Expose an API for other plugins to interact with drupalimage widgets. + CKEDITOR.plugins.drupalimage = { + getFocusedWidget: getFocusedWidget + }; + +})(jQuery, Drupal, CKEDITOR); diff --git a/core/modules/ckeditor/js/plugins/drupalimage/plugin.js b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js index 0025664e09f7..da9171958561 100644 --- a/core/modules/ckeditor/js/plugins/drupalimage/plugin.js +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js @@ -1,17 +1,10 @@ /** - * @file - * Drupal Image plugin. - * - * This alters the existing CKEditor image2 widget plugin to: - * - require a data-entity-type and a data-entity-uuid attribute (which Drupal - * uses to track where images are being used) - * - use a Drupal-native dialog (that is in fact just an alterable Drupal form - * like any other) instead of CKEditor's own dialogs. - * - * @see \Drupal\editor\Form\EditorImageDialog - * - * @ignore - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/plugins/drupalimage/plugin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, CKEDITOR) { @@ -22,25 +15,13 @@ icons: 'drupalimage', hidpi: true, - beforeInit: function (editor) { - // Override the image2 widget definition to require and handle the - // additional data-entity-type and data-entity-uuid attributes. + beforeInit: function beforeInit(editor) { editor.on('widgetDefinition', function (event) { var widgetDefinition = event.data; if (widgetDefinition.name !== 'image') { return; } - // First, convert requiredContent & allowedContent from the string - // format that image2 uses for both to formats that are better suited - // for extending, so that both this basic drupalimage plugin and Drupal - // modules can easily extend it. - // @see http://docs.ckeditor.com/#!/api/CKEDITOR.filter.allowedContentRules - // Mapped from image2's allowedContent. Unlike image2, we don't allow - // <figure>, <figcaption>, <div> or <p> in our downcast, so we omit - // those. For the <img> tag, we list all attributes it lists, but omit - // the classes, because the listed classes are for alignment, and for - // alignment we use the data-align attribute. widgetDefinition.allowedContent = { img: { attributes: { @@ -52,10 +33,7 @@ classes: {} } }; - // Mapped from image2's requiredContent: "img[src,alt]". This does not - // use the object format unlike above, but a CKEDITOR.style instance, - // because requiredContent does not support the object format. - // @see https://www.drupal.org/node/2585173#comment-10456981 + widgetDefinition.requiredContent = new CKEDITOR.style({ element: 'img', attributes: { @@ -64,10 +42,6 @@ } }); - // Extend requiredContent & allowedContent. - // CKEDITOR.style is an immutable object: we cannot modify its - // definition to extend requiredContent. Hence we get the definition, - // modify it, and pass it to a new CKEDITOR.style instance. var requiredContent = widgetDefinition.requiredContent.getDefinition(); requiredContent.attributes['data-entity-type'] = ''; requiredContent.attributes['data-entity-uuid'] = ''; @@ -75,44 +49,25 @@ widgetDefinition.allowedContent.img.attributes['!data-entity-type'] = true; widgetDefinition.allowedContent.img.attributes['!data-entity-uuid'] = true; - // Override downcast(): since we only accept <img> in our upcast method, - // the element is already correct. We only need to update the element's - // data-entity-uuid attribute. widgetDefinition.downcast = function (element) { element.attributes['data-entity-type'] = this.data['data-entity-type']; element.attributes['data-entity-uuid'] = this.data['data-entity-uuid']; }; - // We want to upcast <img> elements to a DOM structure required by the - // image2 widget; we only accept an <img> tag, and that <img> tag MAY - // have a data-entity-type and a data-entity-uuid attribute. widgetDefinition.upcast = function (element, data) { if (element.name !== 'img') { return; - } - // Don't initialize on pasted fake objects. - else if (element.attributes['data-cke-realelement']) { - return; - } + } else if (element.attributes['data-cke-realelement']) { + return; + } - // Parse the data-entity-type attribute. data['data-entity-type'] = element.attributes['data-entity-type']; - // Parse the data-entity-uuid attribute. + data['data-entity-uuid'] = element.attributes['data-entity-uuid']; return element; }; - // Overrides default implementation. Used to populate the "classes" - // property of the widget's "data" property, which is used for the - // "widget styles" functionality - // (http://docs.ckeditor.com/#!/guide/dev_styles-section-widget-styles). - // Is applied to whatever the main element of the widget is (<figure> or - // <img>). The classes in image2_captionedClass are always added due to - // a bug in CKEditor. In the case of drupalimage, we don't ever want to - // add that class, because the widget template already contains it. - // @see http://dev.ckeditor.com/ticket/13888 - // @see https://www.drupal.org/node/2268941 var originalGetClasses = widgetDefinition.getClasses; widgetDefinition.getClasses = function () { var classes = originalGetClasses.call(this); @@ -129,9 +84,6 @@ return classes; }; - // Protected; keys of the widget data to be sent to the Drupal dialog. - // Keys in the hash are the keys for image2's data, values are the keys - // that the Drupal dialog uses. widgetDefinition._mapDataToDialog = { 'src': 'src', 'alt': 'alt', @@ -141,9 +93,6 @@ 'data-entity-uuid': 'data-entity-uuid' }; - // Protected; transforms widget's data object to the format used by the - // \Drupal\editor\Form\EditorImageDialog dialog, keeping only the data - // listed in widgetDefinition._dataForDialog. widgetDefinition._dataToDialogValues = function (data) { var dialogValues = {}; var map = widgetDefinition._mapDataToDialog; @@ -153,7 +102,6 @@ return dialogValues; }; - // Protected; the inverse of _dataToDialogValues. widgetDefinition._dialogValuesToData = function (dialogReturnValues) { var data = {}; var map = widgetDefinition._mapDataToDialog; @@ -165,44 +113,31 @@ return data; }; - // Protected; creates Drupal dialog save callback. widgetDefinition._createDialogSaveCallback = function (editor, widget) { return function (dialogReturnValues) { var firstEdit = !widget.ready; - // Dialog may have blurred the widget. Re-focus it first. if (!firstEdit) { widget.focus(); } editor.fire('saveSnapshot'); - // Pass `true` so DocumentFragment will also be returned. var container = widget.wrapper.getParent(true); var image = widget.parts.image; - // Set the updated widget data, after the necessary conversions from - // the dialog's return values. - // Note: on widget#setData this widget instance might be destroyed. var data = widgetDefinition._dialogValuesToData(dialogReturnValues.attributes); widget.setData(data); - // Retrieve the widget once again. It could've been destroyed - // when shifting state, so might deal with a new instance. widget = editor.widgets.getByElement(image); - // It's first edit, just after widget instance creation, but before - // it was inserted into DOM. So we need to retrieve the widget - // wrapper from inside the DocumentFragment which we cached above - // and finalize other things (like ready event and flag). if (firstEdit) { editor.widgets.finalizeCreation(container); } setTimeout(function () { - // (Re-)focus the widget. widget.focus(); - // Save snapshot for undo support. + editor.fire('saveSnapshot'); }); @@ -214,19 +149,12 @@ widgetDefinition.init = function () { originalInit.call(this); - // Update data.link object with attributes if the link has been - // discovered. - // @see plugins/image2/plugin.js/init() in CKEditor; this is similar. if (this.parts.link) { this.setData('link', CKEDITOR.plugins.image2.getLinkAttributesParser()(editor, this.parts.link)); } }; }); - // Add a widget#edit listener to every instance of image2 widget in order - // to handle its editing with a Drupal-native dialog. - // This includes also a case just after the image was created - // and dialog should be opened for it for the first time. editor.widgets.on('instanceCreated', function (event) { var widget = event.data; @@ -235,30 +163,23 @@ } widget.on('edit', function (event) { - // Cancel edit event to break image2's dialog binding - // (and also to prevent automatic insertion before opening dialog). event.cancel(); - // Open drupalimage dialog. editor.execCommand('editdrupalimage', { existingValues: widget.definition._dataToDialogValues(widget.data), saveCallback: widget.definition._createDialogSaveCallback(editor, widget), - // Drupal.t() will not work inside CKEditor plugins because CKEditor - // loads the JavaScript file instead of Drupal. Pull translated - // strings from the plugin settings that are translated server-side. + dialogTitle: widget.data.src ? editor.config.drupalImage_dialogTitleEdit : editor.config.drupalImage_dialogTitleAdd }); }); }); - // Register the "editdrupalimage" command, which essentially just replaces - // the "image" command's CKEditor dialog with a Drupal-native dialog. editor.addCommand('editdrupalimage', { allowedContent: 'img[alt,!src,width,height,!data-entity-type,!data-entity-uuid]', requiredContent: 'img[alt,src,data-entity-type,data-entity-uuid]', - modes: {wysiwyg: 1}, + modes: { wysiwyg: 1 }, canUndo: true, - exec: function (editor, data) { + exec: function exec(editor, data) { var dialogSettings = { title: data.dialogTitle, dialogClass: 'editor-image-dialog' @@ -267,24 +188,21 @@ } }); - // Register the toolbar button. if (editor.ui.addButton) { editor.ui.addButton('DrupalImage', { label: Drupal.t('Image'), - // Note that we use the original image2 command! + command: 'image' }); } }, - afterInit: function (editor) { + afterInit: function afterInit(editor) { linkCommandIntegrator(editor); } }); - // Override image2's integration with the official CKEditor link plugin: - // integrate with the drupallink plugin instead. CKEDITOR.plugins.image2.getLinkAttributesParser = function () { return CKEDITOR.plugins.drupallink.parseLinkAttributes; }; @@ -292,42 +210,25 @@ return CKEDITOR.plugins.drupallink.getLinkAttributes; }; - /** - * Integrates the drupalimage widget with the drupallink plugin. - * - * Makes images linkable. - * - * @param {CKEDITOR.editor} editor - * A CKEditor instance. - */ function linkCommandIntegrator(editor) { - // Nothing to integrate with if the drupallink plugin is not loaded. if (!editor.plugins.drupallink) { return; } - // Override default behaviour of 'drupalunlink' command. editor.getCommand('drupalunlink').on('exec', function (evt) { var widget = getFocusedWidget(editor); - // Override 'drupalunlink' only when link truly belongs to the widget. If - // wrapped inline widget in a link, let default unlink work. - // @see https://dev.ckeditor.com/ticket/11814 if (!widget || !widget.parts.link) { return; } widget.setData('link', null); - // Selection (which is fake) may not change if unlinked image in focused - // widget, i.e. if captioned image. Let's refresh command state manually - // here. this.refresh(editor, editor.elementPath()); evt.cancel(); }); - // Override default refresh of 'drupalunlink' command. editor.getCommand('drupalunlink').on('refresh', function (evt) { var widget = getFocusedWidget(editor); @@ -335,24 +236,12 @@ return; } - // Note that widget may be wrapped in a link, which - // does not belong to that widget (#11814). - this.setState(widget.data.link || widget.wrapper.getAscendant('a') ? - CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED); + this.setState(widget.data.link || widget.wrapper.getAscendant('a') ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED); evt.cancel(); }); } - /** - * Gets the focused widget, if of the type specific for this plugin. - * - * @param {CKEDITOR.editor} editor - * A CKEditor instance. - * - * @return {?CKEDITOR.plugins.widget} - * The focused image2 widget instance, or null. - */ function getFocusedWidget(editor) { var widget = editor.widgets.focused; @@ -363,9 +252,7 @@ return null; } - // Expose an API for other plugins to interact with drupalimage widgets. CKEDITOR.plugins.drupalimage = { getFocusedWidget: getFocusedWidget }; - -})(jQuery, Drupal, CKEDITOR); +})(jQuery, Drupal, CKEDITOR); \ No newline at end of file diff --git a/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.es6.js b/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.es6.js new file mode 100644 index 000000000000..d1d666c5b945 --- /dev/null +++ b/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.es6.js @@ -0,0 +1,301 @@ +/** + * @file + * Drupal Image Caption plugin. + * + * This alters the existing CKEditor image2 widget plugin, which is already + * altered by the Drupal Image plugin, to: + * - allow for the data-caption and data-align attributes to be set + * - mimic the upcasting behavior of the caption_filter filter. + * + * @ignore + */ + +(function (CKEDITOR) { + + 'use strict'; + + CKEDITOR.plugins.add('drupalimagecaption', { + requires: 'drupalimage', + + beforeInit: function (editor) { + // Disable default placeholder text that comes with CKEditor's image2 + // plugin: it has an inferior UX (it requires the user to manually delete + // the place holder text). + editor.lang.image2.captionPlaceholder = ''; + + // Drupal.t() will not work inside CKEditor plugins because CKEditor loads + // the JavaScript file instead of Drupal. Pull translated strings from the + // plugin settings that are translated server-side. + var placeholderText = editor.config.drupalImageCaption_captionPlaceholderText; + + // Override the image2 widget definition to handle the additional + // data-align and data-caption attributes. + editor.on('widgetDefinition', function (event) { + var widgetDefinition = event.data; + if (widgetDefinition.name !== 'image') { + return; + } + + // Only perform the downcasting/upcasting for to the enabled filters. + var captionFilterEnabled = editor.config.drupalImageCaption_captionFilterEnabled; + var alignFilterEnabled = editor.config.drupalImageCaption_alignFilterEnabled; + + // Override default features definitions for drupalimagecaption. + CKEDITOR.tools.extend(widgetDefinition.features, { + caption: { + requiredContent: 'img[data-caption]' + }, + align: { + requiredContent: 'img[data-align]' + } + }, true); + + // Extend requiredContent & allowedContent. + // CKEDITOR.style is an immutable object: we cannot modify its + // definition to extend requiredContent. Hence we get the definition, + // modify it, and pass it to a new CKEDITOR.style instance. + var requiredContent = widgetDefinition.requiredContent.getDefinition(); + requiredContent.attributes['data-align'] = ''; + requiredContent.attributes['data-caption'] = ''; + widgetDefinition.requiredContent = new CKEDITOR.style(requiredContent); + widgetDefinition.allowedContent.img.attributes['!data-align'] = true; + widgetDefinition.allowedContent.img.attributes['!data-caption'] = true; + + // Override allowedContent setting for the 'caption' nested editable. + // This must match what caption_filter enforces. + // @see \Drupal\filter\Plugin\Filter\FilterCaption::process() + // @see \Drupal\Component\Utility\Xss::filter() + widgetDefinition.editables.caption.allowedContent = 'a[!href]; em strong cite code br'; + + // Override downcast(): ensure we *only* output <img>, but also ensure + // we include the data-entity-type, data-entity-uuid, data-align and + // data-caption attributes. + var originalDowncast = widgetDefinition.downcast; + widgetDefinition.downcast = function (element) { + var img = findElementByName(element, 'img'); + originalDowncast.call(this, img); + + var caption = this.editables.caption; + var captionHtml = caption && caption.getData(); + var attrs = img.attributes; + + if (captionFilterEnabled) { + // If image contains a non-empty caption, serialize caption to the + // data-caption attribute. + if (captionHtml) { + attrs['data-caption'] = captionHtml; + } + } + if (alignFilterEnabled) { + if (this.data.align !== 'none') { + attrs['data-align'] = this.data.align; + } + } + + // If img is wrapped with a link, we want to return that link. + if (img.parent.name === 'a') { + return img.parent; + } + else { + return img; + } + }; + + // We want to upcast <img> elements to a DOM structure required by the + // image2 widget. Depending on a case it may be: + // - just an <img> tag (non-captioned, not-centered image), + // - <img> tag in a paragraph (non-captioned, centered image), + // - <figure> tag (captioned image). + // We take the same attributes into account as downcast() does. + var originalUpcast = widgetDefinition.upcast; + widgetDefinition.upcast = function (element, data) { + if (element.name !== 'img' || !element.attributes['data-entity-type'] || !element.attributes['data-entity-uuid']) { + return; + } + // Don't initialize on pasted fake objects. + else if (element.attributes['data-cke-realelement']) { + return; + } + + element = originalUpcast.call(this, element, data); + var attrs = element.attributes; + + if (element.parent.name === 'a') { + element = element.parent; + } + + var retElement = element; + var caption; + + // We won't need the attributes during editing: we'll use widget.data + // to store them (except the caption, which is stored in the DOM). + if (captionFilterEnabled) { + caption = attrs['data-caption']; + delete attrs['data-caption']; + } + if (alignFilterEnabled) { + data.align = attrs['data-align']; + delete attrs['data-align']; + } + data['data-entity-type'] = attrs['data-entity-type']; + delete attrs['data-entity-type']; + data['data-entity-uuid'] = attrs['data-entity-uuid']; + delete attrs['data-entity-uuid']; + + if (captionFilterEnabled) { + // Unwrap from <p> wrapper created by HTML parser for a captioned + // image. The captioned image will be transformed to <figure>, so we + // don't want the <p> anymore. + if (element.parent.name === 'p' && caption) { + var index = element.getIndex(); + var splitBefore = index > 0; + var splitAfter = index + 1 < element.parent.children.length; + + if (splitBefore) { + element.parent.split(index); + } + index = element.getIndex(); + if (splitAfter) { + element.parent.split(index + 1); + } + + element.parent.replaceWith(element); + retElement = element; + } + + // If this image has a caption, create a full <figure> structure. + if (caption) { + var figure = new CKEDITOR.htmlParser.element('figure'); + caption = new CKEDITOR.htmlParser.fragment.fromHtml(caption, 'figcaption'); + + // Use Drupal's data-placeholder attribute to insert a CSS-based, + // translation-ready placeholder for empty captions. Note that it + // also must to be done for new instances (see + // widgetDefinition._createDialogSaveCallback). + caption.attributes['data-placeholder'] = placeholderText; + + element.replaceWith(figure); + figure.add(element); + figure.add(caption); + figure.attributes['class'] = editor.config.image2_captionedClass; + retElement = figure; + } + } + + if (alignFilterEnabled) { + // If this image doesn't have a caption (or the caption filter is + // disabled), but it is centered, make sure that it's wrapped with + // <p>, which will become a part of the widget. + if (data.align === 'center' && (!captionFilterEnabled || !caption)) { + var p = new CKEDITOR.htmlParser.element('p'); + element.replaceWith(p); + p.add(element); + // Apply the class for centered images. + p.addClass(editor.config.image2_alignClasses[1]); + retElement = p; + } + } + + // Return the upcasted element (<img>, <figure> or <p>). + return retElement; + }; + + // Protected; keys of the widget data to be sent to the Drupal dialog. + // Append to the values defined by the drupalimage plugin. + // @see core/modules/ckeditor/js/plugins/drupalimage/plugin.js + CKEDITOR.tools.extend(widgetDefinition._mapDataToDialog, { + 'align': 'data-align', + 'data-caption': 'data-caption', + 'hasCaption': 'hasCaption' + }); + + // Override Drupal dialog save callback. + var originalCreateDialogSaveCallback = widgetDefinition._createDialogSaveCallback; + widgetDefinition._createDialogSaveCallback = function (editor, widget) { + var saveCallback = originalCreateDialogSaveCallback.call(this, editor, widget); + + return function (dialogReturnValues) { + // Ensure hasCaption is a boolean. image2 assumes it always works + // with booleans; if this is not the case, then + // CKEDITOR.plugins.image2.stateShifter() will incorrectly mark + // widget.data.hasCaption as "changed" (e.g. when hasCaption === 0 + // instead of hasCaption === false). This causes image2's "state + // shifter" to enter the wrong branch of the algorithm and blow up. + dialogReturnValues.attributes.hasCaption = !!dialogReturnValues.attributes.hasCaption; + + var actualWidget = saveCallback(dialogReturnValues); + + // By default, the template of captioned widget has no + // data-placeholder attribute. Note that it also must be done when + // upcasting existing elements (see widgetDefinition.upcast). + if (dialogReturnValues.attributes.hasCaption) { + actualWidget.editables.caption.setAttribute('data-placeholder', placeholderText); + + // Some browsers will add a <br> tag to a newly created DOM + // element with no content. Remove this <br> if it is the only + // thing in the caption. Our placeholder support requires the + // element be entirely empty. See filter-caption.css. + var captionElement = actualWidget.editables.caption.$; + if (captionElement.childNodes.length === 1 && captionElement.childNodes.item(0).nodeName === 'BR') { + captionElement.removeChild(captionElement.childNodes.item(0)); + } + } + }; + }; + // Low priority to ensure drupalimage's event handler runs first. + }, null, null, 20); + }, + + afterInit: function (editor) { + var disableButtonIfOnWidget = function (evt) { + var widget = editor.widgets.focused; + if (widget && widget.name === 'image') { + this.setState(CKEDITOR.TRISTATE_DISABLED); + evt.cancel(); + } + }; + + // Disable alignment buttons if the align filter is not enabled. + if (editor.plugins.justify && !editor.config.drupalImageCaption_alignFilterEnabled) { + var cmd; + var commands = ['justifyleft', 'justifycenter', 'justifyright', 'justifyblock']; + for (var n = 0; n < commands.length; n++) { + cmd = editor.getCommand(commands[n]); + cmd.contextSensitive = 1; + cmd.on('refresh', disableButtonIfOnWidget, null, null, 4); + } + } + } + }); + + /** + * Finds an element by its name. + * + * Function will check first the passed element itself and then all its + * children in DFS order. + * + * @param {CKEDITOR.htmlParser.element} element + * The element to search. + * @param {string} name + * The element name to search for. + * + * @return {?CKEDITOR.htmlParser.element} + * The found element, or null. + */ + function findElementByName(element, name) { + if (element.name === name) { + return element; + } + + var found = null; + element.forEach(function (el) { + if (el.name === name) { + found = el; + // Stop here. + return false; + } + }, CKEDITOR.NODE_ELEMENT); + return found; + } + +})(CKEDITOR); diff --git a/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js b/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js index d1d666c5b945..4928c320a5e9 100644 --- a/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js +++ b/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js @@ -1,14 +1,10 @@ /** - * @file - * Drupal Image Caption plugin. - * - * This alters the existing CKEditor image2 widget plugin, which is already - * altered by the Drupal Image plugin, to: - * - allow for the data-caption and data-align attributes to be set - * - mimic the upcasting behavior of the caption_filter filter. - * - * @ignore - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/plugins/drupalimagecaption/plugin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (CKEDITOR) { @@ -17,30 +13,20 @@ CKEDITOR.plugins.add('drupalimagecaption', { requires: 'drupalimage', - beforeInit: function (editor) { - // Disable default placeholder text that comes with CKEditor's image2 - // plugin: it has an inferior UX (it requires the user to manually delete - // the place holder text). + beforeInit: function beforeInit(editor) { editor.lang.image2.captionPlaceholder = ''; - // Drupal.t() will not work inside CKEditor plugins because CKEditor loads - // the JavaScript file instead of Drupal. Pull translated strings from the - // plugin settings that are translated server-side. var placeholderText = editor.config.drupalImageCaption_captionPlaceholderText; - // Override the image2 widget definition to handle the additional - // data-align and data-caption attributes. editor.on('widgetDefinition', function (event) { var widgetDefinition = event.data; if (widgetDefinition.name !== 'image') { return; } - // Only perform the downcasting/upcasting for to the enabled filters. var captionFilterEnabled = editor.config.drupalImageCaption_captionFilterEnabled; var alignFilterEnabled = editor.config.drupalImageCaption_alignFilterEnabled; - // Override default features definitions for drupalimagecaption. CKEDITOR.tools.extend(widgetDefinition.features, { caption: { requiredContent: 'img[data-caption]' @@ -50,10 +36,6 @@ } }, true); - // Extend requiredContent & allowedContent. - // CKEDITOR.style is an immutable object: we cannot modify its - // definition to extend requiredContent. Hence we get the definition, - // modify it, and pass it to a new CKEDITOR.style instance. var requiredContent = widgetDefinition.requiredContent.getDefinition(); requiredContent.attributes['data-align'] = ''; requiredContent.attributes['data-caption'] = ''; @@ -61,15 +43,8 @@ widgetDefinition.allowedContent.img.attributes['!data-align'] = true; widgetDefinition.allowedContent.img.attributes['!data-caption'] = true; - // Override allowedContent setting for the 'caption' nested editable. - // This must match what caption_filter enforces. - // @see \Drupal\filter\Plugin\Filter\FilterCaption::process() - // @see \Drupal\Component\Utility\Xss::filter() widgetDefinition.editables.caption.allowedContent = 'a[!href]; em strong cite code br'; - // Override downcast(): ensure we *only* output <img>, but also ensure - // we include the data-entity-type, data-entity-uuid, data-align and - // data-caption attributes. var originalDowncast = widgetDefinition.downcast; widgetDefinition.downcast = function (element) { var img = findElementByName(element, 'img'); @@ -80,8 +55,6 @@ var attrs = img.attributes; if (captionFilterEnabled) { - // If image contains a non-empty caption, serialize caption to the - // data-caption attribute. if (captionHtml) { attrs['data-caption'] = captionHtml; } @@ -92,30 +65,20 @@ } } - // If img is wrapped with a link, we want to return that link. if (img.parent.name === 'a') { return img.parent; - } - else { + } else { return img; } }; - // We want to upcast <img> elements to a DOM structure required by the - // image2 widget. Depending on a case it may be: - // - just an <img> tag (non-captioned, not-centered image), - // - <img> tag in a paragraph (non-captioned, centered image), - // - <figure> tag (captioned image). - // We take the same attributes into account as downcast() does. var originalUpcast = widgetDefinition.upcast; widgetDefinition.upcast = function (element, data) { if (element.name !== 'img' || !element.attributes['data-entity-type'] || !element.attributes['data-entity-uuid']) { return; - } - // Don't initialize on pasted fake objects. - else if (element.attributes['data-cke-realelement']) { - return; - } + } else if (element.attributes['data-cke-realelement']) { + return; + } element = originalUpcast.call(this, element, data); var attrs = element.attributes; @@ -127,8 +90,6 @@ var retElement = element; var caption; - // We won't need the attributes during editing: we'll use widget.data - // to store them (except the caption, which is stored in the DOM). if (captionFilterEnabled) { caption = attrs['data-caption']; delete attrs['data-caption']; @@ -143,9 +104,6 @@ delete attrs['data-entity-uuid']; if (captionFilterEnabled) { - // Unwrap from <p> wrapper created by HTML parser for a captioned - // image. The captioned image will be transformed to <figure>, so we - // don't want the <p> anymore. if (element.parent.name === 'p' && caption) { var index = element.getIndex(); var splitBefore = index > 0; @@ -163,15 +121,10 @@ retElement = element; } - // If this image has a caption, create a full <figure> structure. if (caption) { var figure = new CKEDITOR.htmlParser.element('figure'); caption = new CKEDITOR.htmlParser.fragment.fromHtml(caption, 'figcaption'); - // Use Drupal's data-placeholder attribute to insert a CSS-based, - // translation-ready placeholder for empty captions. Note that it - // also must to be done for new instances (see - // widgetDefinition._createDialogSaveCallback). caption.attributes['data-placeholder'] = placeholderText; element.replaceWith(figure); @@ -183,58 +136,37 @@ } if (alignFilterEnabled) { - // If this image doesn't have a caption (or the caption filter is - // disabled), but it is centered, make sure that it's wrapped with - // <p>, which will become a part of the widget. if (data.align === 'center' && (!captionFilterEnabled || !caption)) { var p = new CKEDITOR.htmlParser.element('p'); element.replaceWith(p); p.add(element); - // Apply the class for centered images. + p.addClass(editor.config.image2_alignClasses[1]); retElement = p; } } - // Return the upcasted element (<img>, <figure> or <p>). return retElement; }; - // Protected; keys of the widget data to be sent to the Drupal dialog. - // Append to the values defined by the drupalimage plugin. - // @see core/modules/ckeditor/js/plugins/drupalimage/plugin.js CKEDITOR.tools.extend(widgetDefinition._mapDataToDialog, { 'align': 'data-align', 'data-caption': 'data-caption', 'hasCaption': 'hasCaption' }); - // Override Drupal dialog save callback. var originalCreateDialogSaveCallback = widgetDefinition._createDialogSaveCallback; widgetDefinition._createDialogSaveCallback = function (editor, widget) { var saveCallback = originalCreateDialogSaveCallback.call(this, editor, widget); return function (dialogReturnValues) { - // Ensure hasCaption is a boolean. image2 assumes it always works - // with booleans; if this is not the case, then - // CKEDITOR.plugins.image2.stateShifter() will incorrectly mark - // widget.data.hasCaption as "changed" (e.g. when hasCaption === 0 - // instead of hasCaption === false). This causes image2's "state - // shifter" to enter the wrong branch of the algorithm and blow up. dialogReturnValues.attributes.hasCaption = !!dialogReturnValues.attributes.hasCaption; var actualWidget = saveCallback(dialogReturnValues); - // By default, the template of captioned widget has no - // data-placeholder attribute. Note that it also must be done when - // upcasting existing elements (see widgetDefinition.upcast). if (dialogReturnValues.attributes.hasCaption) { actualWidget.editables.caption.setAttribute('data-placeholder', placeholderText); - // Some browsers will add a <br> tag to a newly created DOM - // element with no content. Remove this <br> if it is the only - // thing in the caption. Our placeholder support requires the - // element be entirely empty. See filter-caption.css. var captionElement = actualWidget.editables.caption.$; if (captionElement.childNodes.length === 1 && captionElement.childNodes.item(0).nodeName === 'BR') { captionElement.removeChild(captionElement.childNodes.item(0)); @@ -242,12 +174,11 @@ } }; }; - // Low priority to ensure drupalimage's event handler runs first. }, null, null, 20); }, - afterInit: function (editor) { - var disableButtonIfOnWidget = function (evt) { + afterInit: function afterInit(editor) { + var disableButtonIfOnWidget = function disableButtonIfOnWidget(evt) { var widget = editor.widgets.focused; if (widget && widget.name === 'image') { this.setState(CKEDITOR.TRISTATE_DISABLED); @@ -255,7 +186,6 @@ } }; - // Disable alignment buttons if the align filter is not enabled. if (editor.plugins.justify && !editor.config.drupalImageCaption_alignFilterEnabled) { var cmd; var commands = ['justifyleft', 'justifycenter', 'justifyright', 'justifyblock']; @@ -268,20 +198,6 @@ } }); - /** - * Finds an element by its name. - * - * Function will check first the passed element itself and then all its - * children in DFS order. - * - * @param {CKEDITOR.htmlParser.element} element - * The element to search. - * @param {string} name - * The element name to search for. - * - * @return {?CKEDITOR.htmlParser.element} - * The found element, or null. - */ function findElementByName(element, name) { if (element.name === name) { return element; @@ -291,11 +207,10 @@ element.forEach(function (el) { if (el.name === name) { found = el; - // Stop here. + return false; } }, CKEDITOR.NODE_ELEMENT); return found; } - -})(CKEDITOR); +})(CKEDITOR); \ No newline at end of file diff --git a/core/modules/ckeditor/js/plugins/drupallink/plugin.es6.js b/core/modules/ckeditor/js/plugins/drupallink/plugin.es6.js new file mode 100644 index 000000000000..9bd4dce98506 --- /dev/null +++ b/core/modules/ckeditor/js/plugins/drupallink/plugin.es6.js @@ -0,0 +1,304 @@ +/** + * @file + * Drupal Link plugin. + * + * @ignore + */ + +(function ($, Drupal, drupalSettings, CKEDITOR) { + + 'use strict'; + + function parseAttributes(editor, element) { + var parsedAttributes = {}; + + var domElement = element.$; + var attribute; + var attributeName; + for (var attrIndex = 0; attrIndex < domElement.attributes.length; attrIndex++) { + attribute = domElement.attributes.item(attrIndex); + attributeName = attribute.nodeName.toLowerCase(); + // Ignore data-cke-* attributes; they're CKEditor internals. + if (attributeName.indexOf('data-cke-') === 0) { + continue; + } + // Store the value for this attribute, unless there's a data-cke-saved- + // alternative for it, which will contain the quirk-free, original value. + parsedAttributes[attributeName] = element.data('cke-saved-' + attributeName) || attribute.nodeValue; + } + + // Remove any cke_* classes. + if (parsedAttributes.class) { + parsedAttributes.class = CKEDITOR.tools.trim(parsedAttributes.class.replace(/cke_\S+/, '')); + } + + return parsedAttributes; + } + + function getAttributes(editor, data) { + var set = {}; + for (var attributeName in data) { + if (data.hasOwnProperty(attributeName)) { + set[attributeName] = data[attributeName]; + } + } + + // CKEditor tracks the *actual* saved href in a data-cke-saved-* attribute + // to work around browser quirks. We need to update it. + set['data-cke-saved-href'] = set.href; + + // Remove all attributes which are not currently set. + var removed = {}; + for (var s in set) { + if (set.hasOwnProperty(s)) { + delete removed[s]; + } + } + + return { + set: set, + removed: CKEDITOR.tools.objectKeys(removed) + }; + } + + CKEDITOR.plugins.add('drupallink', { + icons: 'drupallink,drupalunlink', + hidpi: true, + + init: function (editor) { + // Add the commands for link and unlink. + editor.addCommand('drupallink', { + allowedContent: { + a: { + attributes: { + '!href': true + }, + classes: {} + } + }, + requiredContent: new CKEDITOR.style({ + element: 'a', + attributes: { + href: '' + } + }), + modes: {wysiwyg: 1}, + canUndo: true, + exec: function (editor) { + var drupalImageUtils = CKEDITOR.plugins.drupalimage; + var focusedImageWidget = drupalImageUtils && drupalImageUtils.getFocusedWidget(editor); + var linkElement = getSelectedLink(editor); + + // Set existing values based on selected element. + var existingValues = {}; + if (linkElement && linkElement.$) { + existingValues = parseAttributes(editor, linkElement); + } + // Or, if an image widget is focused, we're editing a link wrapping + // an image widget. + else if (focusedImageWidget && focusedImageWidget.data.link) { + existingValues = CKEDITOR.tools.clone(focusedImageWidget.data.link); + } + + // Prepare a save callback to be used upon saving the dialog. + var saveCallback = function (returnValues) { + // If an image widget is focused, we're not editing an independent + // link, but we're wrapping an image widget in a link. + if (focusedImageWidget) { + focusedImageWidget.setData('link', CKEDITOR.tools.extend(returnValues.attributes, focusedImageWidget.data.link)); + editor.fire('saveSnapshot'); + return; + } + + editor.fire('saveSnapshot'); + + // Create a new link element if needed. + if (!linkElement && returnValues.attributes.href) { + var selection = editor.getSelection(); + var range = selection.getRanges(1)[0]; + + // Use link URL as text with a collapsed cursor. + if (range.collapsed) { + // Shorten mailto URLs to just the email address. + var text = new CKEDITOR.dom.text(returnValues.attributes.href.replace(/^mailto:/, ''), editor.document); + range.insertNode(text); + range.selectNodeContents(text); + } + + // Create the new link by applying a style to the new text. + var style = new CKEDITOR.style({element: 'a', attributes: returnValues.attributes}); + style.type = CKEDITOR.STYLE_INLINE; + style.applyToRange(range); + range.select(); + + // Set the link so individual properties may be set below. + linkElement = getSelectedLink(editor); + } + // Update the link properties. + else if (linkElement) { + for (var attrName in returnValues.attributes) { + if (returnValues.attributes.hasOwnProperty(attrName)) { + // Update the property if a value is specified. + if (returnValues.attributes[attrName].length > 0) { + var value = returnValues.attributes[attrName]; + linkElement.data('cke-saved-' + attrName, value); + linkElement.setAttribute(attrName, value); + } + // Delete the property if set to an empty string. + else { + linkElement.removeAttribute(attrName); + } + } + } + } + + // Save snapshot for undo support. + editor.fire('saveSnapshot'); + }; + // Drupal.t() will not work inside CKEditor plugins because CKEditor + // loads the JavaScript file instead of Drupal. Pull translated + // strings from the plugin settings that are translated server-side. + var dialogSettings = { + title: linkElement ? editor.config.drupalLink_dialogTitleEdit : editor.config.drupalLink_dialogTitleAdd, + dialogClass: 'editor-link-dialog' + }; + + // Open the dialog for the edit form. + Drupal.ckeditor.openDialog(editor, Drupal.url('editor/dialog/link/' + editor.config.drupal.format), existingValues, saveCallback, dialogSettings); + } + }); + editor.addCommand('drupalunlink', { + contextSensitive: 1, + startDisabled: 1, + requiredContent: new CKEDITOR.style({ + element: 'a', + attributes: { + href: '' + } + }), + exec: function (editor) { + var style = new CKEDITOR.style({element: 'a', type: CKEDITOR.STYLE_INLINE, alwaysRemoveElement: 1}); + editor.removeStyle(style); + }, + refresh: function (editor, path) { + var element = path.lastElement && path.lastElement.getAscendant('a', true); + if (element && element.getName() === 'a' && element.getAttribute('href') && element.getChildCount()) { + this.setState(CKEDITOR.TRISTATE_OFF); + } + else { + this.setState(CKEDITOR.TRISTATE_DISABLED); + } + } + }); + + // CTRL + K. + editor.setKeystroke(CKEDITOR.CTRL + 75, 'drupallink'); + + // Add buttons for link and unlink. + if (editor.ui.addButton) { + editor.ui.addButton('DrupalLink', { + label: Drupal.t('Link'), + command: 'drupallink' + }); + editor.ui.addButton('DrupalUnlink', { + label: Drupal.t('Unlink'), + command: 'drupalunlink' + }); + } + + editor.on('doubleclick', function (evt) { + var element = getSelectedLink(editor) || evt.data.element; + + if (!element.isReadOnly()) { + if (element.is('a')) { + editor.getSelection().selectElement(element); + editor.getCommand('drupallink').exec(); + } + } + }); + + // If the "menu" plugin is loaded, register the menu items. + if (editor.addMenuItems) { + editor.addMenuItems({ + link: { + label: Drupal.t('Edit Link'), + command: 'drupallink', + group: 'link', + order: 1 + }, + + unlink: { + label: Drupal.t('Unlink'), + command: 'drupalunlink', + group: 'link', + order: 5 + } + }); + } + + // If the "contextmenu" plugin is loaded, register the listeners. + if (editor.contextMenu) { + editor.contextMenu.addListener(function (element, selection) { + if (!element || element.isReadOnly()) { + return null; + } + var anchor = getSelectedLink(editor); + if (!anchor) { + return null; + } + + var menu = {}; + if (anchor.getAttribute('href') && anchor.getChildCount()) { + menu = {link: CKEDITOR.TRISTATE_OFF, unlink: CKEDITOR.TRISTATE_OFF}; + } + return menu; + }); + } + } + }); + + /** + * Get the surrounding link element of current selection. + * + * The following selection will all return the link element. + * + * @example + * <a href="#">li^nk</a> + * <a href="#">[link]</a> + * text[<a href="#">link]</a> + * <a href="#">li[nk</a>] + * [<b><a href="#">li]nk</a></b>] + * [<a href="#"><b>li]nk</b></a> + * + * @param {CKEDITOR.editor} editor + * The CKEditor editor object + * + * @return {?HTMLElement} + * The selected link element, or null. + * + */ + function getSelectedLink(editor) { + var selection = editor.getSelection(); + var selectedElement = selection.getSelectedElement(); + if (selectedElement && selectedElement.is('a')) { + return selectedElement; + } + + var range = selection.getRanges(true)[0]; + + if (range) { + range.shrink(CKEDITOR.SHRINK_TEXT); + return editor.elementPath(range.getCommonAncestor()).contains('a', 1); + } + return null; + } + + // Expose an API for other plugins to interact with drupallink widgets. + // (Compatible with the official CKEditor link plugin's API: + // http://dev.ckeditor.com/ticket/13885.) + CKEDITOR.plugins.drupallink = { + parseLinkAttributes: parseAttributes, + getLinkAttributes: getAttributes + }; + +})(jQuery, Drupal, drupalSettings, CKEDITOR); diff --git a/core/modules/ckeditor/js/plugins/drupallink/plugin.js b/core/modules/ckeditor/js/plugins/drupallink/plugin.js index 9bd4dce98506..ee6ca366dd56 100644 --- a/core/modules/ckeditor/js/plugins/drupallink/plugin.js +++ b/core/modules/ckeditor/js/plugins/drupallink/plugin.js @@ -1,9 +1,10 @@ /** - * @file - * Drupal Link plugin. - * - * @ignore - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/plugins/drupallink/plugin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings, CKEDITOR) { @@ -18,16 +19,14 @@ for (var attrIndex = 0; attrIndex < domElement.attributes.length; attrIndex++) { attribute = domElement.attributes.item(attrIndex); attributeName = attribute.nodeName.toLowerCase(); - // Ignore data-cke-* attributes; they're CKEditor internals. + if (attributeName.indexOf('data-cke-') === 0) { continue; } - // Store the value for this attribute, unless there's a data-cke-saved- - // alternative for it, which will contain the quirk-free, original value. + parsedAttributes[attributeName] = element.data('cke-saved-' + attributeName) || attribute.nodeValue; } - // Remove any cke_* classes. if (parsedAttributes.class) { parsedAttributes.class = CKEDITOR.tools.trim(parsedAttributes.class.replace(/cke_\S+/, '')); } @@ -43,11 +42,8 @@ } } - // CKEditor tracks the *actual* saved href in a data-cke-saved-* attribute - // to work around browser quirks. We need to update it. set['data-cke-saved-href'] = set.href; - // Remove all attributes which are not currently set. var removed = {}; for (var s in set) { if (set.hasOwnProperty(s)) { @@ -65,8 +61,7 @@ icons: 'drupallink,drupalunlink', hidpi: true, - init: function (editor) { - // Add the commands for link and unlink. + init: function init(editor) { editor.addCommand('drupallink', { allowedContent: { a: { @@ -82,28 +77,21 @@ href: '' } }), - modes: {wysiwyg: 1}, + modes: { wysiwyg: 1 }, canUndo: true, - exec: function (editor) { + exec: function exec(editor) { var drupalImageUtils = CKEDITOR.plugins.drupalimage; var focusedImageWidget = drupalImageUtils && drupalImageUtils.getFocusedWidget(editor); var linkElement = getSelectedLink(editor); - // Set existing values based on selected element. var existingValues = {}; if (linkElement && linkElement.$) { existingValues = parseAttributes(editor, linkElement); - } - // Or, if an image widget is focused, we're editing a link wrapping - // an image widget. - else if (focusedImageWidget && focusedImageWidget.data.link) { - existingValues = CKEDITOR.tools.clone(focusedImageWidget.data.link); - } + } else if (focusedImageWidget && focusedImageWidget.data.link) { + existingValues = CKEDITOR.tools.clone(focusedImageWidget.data.link); + } - // Prepare a save callback to be used upon saving the dialog. - var saveCallback = function (returnValues) { - // If an image widget is focused, we're not editing an independent - // link, but we're wrapping an image widget in a link. + var saveCallback = function saveCallback(returnValues) { if (focusedImageWidget) { focusedImageWidget.setData('link', CKEDITOR.tools.extend(returnValues.attributes, focusedImageWidget.data.link)); editor.fire('saveSnapshot'); @@ -112,58 +100,44 @@ editor.fire('saveSnapshot'); - // Create a new link element if needed. if (!linkElement && returnValues.attributes.href) { var selection = editor.getSelection(); var range = selection.getRanges(1)[0]; - // Use link URL as text with a collapsed cursor. if (range.collapsed) { - // Shorten mailto URLs to just the email address. var text = new CKEDITOR.dom.text(returnValues.attributes.href.replace(/^mailto:/, ''), editor.document); range.insertNode(text); range.selectNodeContents(text); } - // Create the new link by applying a style to the new text. - var style = new CKEDITOR.style({element: 'a', attributes: returnValues.attributes}); + var style = new CKEDITOR.style({ element: 'a', attributes: returnValues.attributes }); style.type = CKEDITOR.STYLE_INLINE; style.applyToRange(range); range.select(); - // Set the link so individual properties may be set below. linkElement = getSelectedLink(editor); - } - // Update the link properties. - else if (linkElement) { - for (var attrName in returnValues.attributes) { - if (returnValues.attributes.hasOwnProperty(attrName)) { - // Update the property if a value is specified. - if (returnValues.attributes[attrName].length > 0) { - var value = returnValues.attributes[attrName]; - linkElement.data('cke-saved-' + attrName, value); - linkElement.setAttribute(attrName, value); - } - // Delete the property if set to an empty string. - else { - linkElement.removeAttribute(attrName); + } else if (linkElement) { + for (var attrName in returnValues.attributes) { + if (returnValues.attributes.hasOwnProperty(attrName)) { + if (returnValues.attributes[attrName].length > 0) { + var value = returnValues.attributes[attrName]; + linkElement.data('cke-saved-' + attrName, value); + linkElement.setAttribute(attrName, value); + } else { + linkElement.removeAttribute(attrName); + } } } } - } - // Save snapshot for undo support. editor.fire('saveSnapshot'); }; - // Drupal.t() will not work inside CKEditor plugins because CKEditor - // loads the JavaScript file instead of Drupal. Pull translated - // strings from the plugin settings that are translated server-side. + var dialogSettings = { title: linkElement ? editor.config.drupalLink_dialogTitleEdit : editor.config.drupalLink_dialogTitleAdd, dialogClass: 'editor-link-dialog' }; - // Open the dialog for the edit form. Drupal.ckeditor.openDialog(editor, Drupal.url('editor/dialog/link/' + editor.config.drupal.format), existingValues, saveCallback, dialogSettings); } }); @@ -176,25 +150,22 @@ href: '' } }), - exec: function (editor) { - var style = new CKEDITOR.style({element: 'a', type: CKEDITOR.STYLE_INLINE, alwaysRemoveElement: 1}); + exec: function exec(editor) { + var style = new CKEDITOR.style({ element: 'a', type: CKEDITOR.STYLE_INLINE, alwaysRemoveElement: 1 }); editor.removeStyle(style); }, - refresh: function (editor, path) { + refresh: function refresh(editor, path) { var element = path.lastElement && path.lastElement.getAscendant('a', true); if (element && element.getName() === 'a' && element.getAttribute('href') && element.getChildCount()) { this.setState(CKEDITOR.TRISTATE_OFF); - } - else { + } else { this.setState(CKEDITOR.TRISTATE_DISABLED); } } }); - // CTRL + K. editor.setKeystroke(CKEDITOR.CTRL + 75, 'drupallink'); - // Add buttons for link and unlink. if (editor.ui.addButton) { editor.ui.addButton('DrupalLink', { label: Drupal.t('Link'), @@ -217,7 +188,6 @@ } }); - // If the "menu" plugin is loaded, register the menu items. if (editor.addMenuItems) { editor.addMenuItems({ link: { @@ -236,7 +206,6 @@ }); } - // If the "contextmenu" plugin is loaded, register the listeners. if (editor.contextMenu) { editor.contextMenu.addListener(function (element, selection) { if (!element || element.isReadOnly()) { @@ -249,7 +218,7 @@ var menu = {}; if (anchor.getAttribute('href') && anchor.getChildCount()) { - menu = {link: CKEDITOR.TRISTATE_OFF, unlink: CKEDITOR.TRISTATE_OFF}; + menu = { link: CKEDITOR.TRISTATE_OFF, unlink: CKEDITOR.TRISTATE_OFF }; } return menu; }); @@ -257,26 +226,6 @@ } }); - /** - * Get the surrounding link element of current selection. - * - * The following selection will all return the link element. - * - * @example - * <a href="#">li^nk</a> - * <a href="#">[link]</a> - * text[<a href="#">link]</a> - * <a href="#">li[nk</a>] - * [<b><a href="#">li]nk</a></b>] - * [<a href="#"><b>li]nk</b></a> - * - * @param {CKEDITOR.editor} editor - * The CKEditor editor object - * - * @return {?HTMLElement} - * The selected link element, or null. - * - */ function getSelectedLink(editor) { var selection = editor.getSelection(); var selectedElement = selection.getSelectedElement(); @@ -293,12 +242,8 @@ return null; } - // Expose an API for other plugins to interact with drupallink widgets. - // (Compatible with the official CKEditor link plugin's API: - // http://dev.ckeditor.com/ticket/13885.) CKEDITOR.plugins.drupallink = { parseLinkAttributes: parseAttributes, getLinkAttributes: getAttributes }; - -})(jQuery, Drupal, drupalSettings, CKEDITOR); +})(jQuery, Drupal, drupalSettings, CKEDITOR); \ No newline at end of file diff --git a/core/modules/ckeditor/js/views/AuralView.es6.js b/core/modules/ckeditor/js/views/AuralView.es6.js new file mode 100644 index 000000000000..0659eb2061c9 --- /dev/null +++ b/core/modules/ckeditor/js/views/AuralView.es6.js @@ -0,0 +1,233 @@ +/** + * @file + * A Backbone View that provides the aural view of CKEditor toolbar + * configuration. + */ + +(function (Drupal, Backbone, $) { + + 'use strict'; + + Drupal.ckeditor.AuralView = Backbone.View.extend(/** @lends Drupal.ckeditor.AuralView# */{ + + /** + * @type {object} + */ + events: { + 'click .ckeditor-buttons a': 'announceButtonHelp', + 'click .ckeditor-multiple-buttons a': 'announceSeparatorHelp', + 'focus .ckeditor-button a': 'onFocus', + 'focus .ckeditor-button-separator a': 'onFocus', + 'focus .ckeditor-toolbar-group': 'onFocus' + }, + + /** + * Backbone View for CKEditor toolbar configuration; aural UX (output only). + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + // Announce the button and group positions when the model is no longer + // dirty. + this.listenTo(this.model, 'change:isDirty', this.announceMove); + }, + + /** + * Calls announce on buttons and groups when their position is changed. + * + * @param {Drupal.ckeditor.ConfigurationModel} model + * The ckeditor configuration model. + * @param {bool} isDirty + * A model attribute that indicates if the changed toolbar configuration + * has been stored or not. + */ + announceMove: function (model, isDirty) { + // Announce the position of a button or group after the model has been + // updated. + if (!isDirty) { + var item = document.activeElement || null; + if (item) { + var $item = $(item); + if ($item.hasClass('ckeditor-toolbar-group')) { + this.announceButtonGroupPosition($item); + } + else if ($item.parent().hasClass('ckeditor-button')) { + this.announceButtonPosition($item.parent()); + } + } + } + }, + + /** + * Handles the focus event of elements in the active and available toolbars. + * + * @param {jQuery.Event} event + * The focus event that was triggered. + */ + onFocus: function (event) { + event.stopPropagation(); + + var $originalTarget = $(event.target); + var $currentTarget = $(event.currentTarget); + var $parent = $currentTarget.parent(); + if ($parent.hasClass('ckeditor-button') || $parent.hasClass('ckeditor-button-separator')) { + this.announceButtonPosition($currentTarget.parent()); + } + else if ($originalTarget.attr('role') !== 'button' && $currentTarget.hasClass('ckeditor-toolbar-group')) { + this.announceButtonGroupPosition($currentTarget); + } + }, + + /** + * Announces the current position of a button group. + * + * @param {jQuery} $group + * A jQuery set that contains an li element that wraps a group of buttons. + */ + announceButtonGroupPosition: function ($group) { + var $groups = $group.parent().children(); + var $row = $group.closest('.ckeditor-row'); + var $rows = $row.parent().children(); + var position = $groups.index($group) + 1; + var positionCount = $groups.not('.placeholder').length; + var row = $rows.index($row) + 1; + var rowCount = $rows.not('.placeholder').length; + var text = Drupal.t('@groupName button group in position @position of @positionCount in row @row of @rowCount.', { + '@groupName': $group.attr('data-drupal-ckeditor-toolbar-group-name'), + '@position': position, + '@positionCount': positionCount, + '@row': row, + '@rowCount': rowCount + }); + // If this position is the first in the last row then tell the user that + // pressing the down arrow key will create a new row. + if (position === 1 && row === rowCount) { + text += '\n'; + text += Drupal.t('Press the down arrow key to create a new row.'); + } + Drupal.announce(text, 'assertive'); + }, + + /** + * Announces current button position. + * + * @param {jQuery} $button + * A jQuery set that contains an li element that wraps a button. + */ + announceButtonPosition: function ($button) { + var $row = $button.closest('.ckeditor-row'); + var $rows = $row.parent().children(); + var $buttons = $button.closest('.ckeditor-buttons').children(); + var $group = $button.closest('.ckeditor-toolbar-group'); + var $groups = $group.parent().children(); + var groupPosition = $groups.index($group) + 1; + var groupPositionCount = $groups.not('.placeholder').length; + var position = $buttons.index($button) + 1; + var positionCount = $buttons.length; + var row = $rows.index($row) + 1; + var rowCount = $rows.not('.placeholder').length; + // The name of the button separator is 'button separator' and its type + // is 'separator', so we do not want to print the type of this item, + // otherwise the UA will speak 'button separator separator'. + var type = ($button.attr('data-drupal-ckeditor-type') === 'separator') ? '' : Drupal.t('button'); + var text; + // The button is located in the available button set. + if ($button.closest('.ckeditor-toolbar-disabled').length > 0) { + text = Drupal.t('@name @type.', { + '@name': $button.children().attr('aria-label'), + '@type': type + }); + text += '\n' + Drupal.t('Press the down arrow key to activate.'); + + Drupal.announce(text, 'assertive'); + } + // The button is in the active toolbar. + else if ($group.not('.placeholder').length === 1) { + text = Drupal.t('@name @type in position @position of @positionCount in @groupName button group in row @row of @rowCount.', { + '@name': $button.children().attr('aria-label'), + '@type': type, + '@position': position, + '@positionCount': positionCount, + '@groupName': $group.attr('data-drupal-ckeditor-toolbar-group-name'), + '@row': row, + '@rowCount': rowCount + }); + // If this position is the first in the last row then tell the user that + // pressing the down arrow key will create a new row. + if (groupPosition === 1 && position === 1 && row === rowCount) { + text += '\n'; + text += Drupal.t('Press the down arrow key to create a new button group in a new row.'); + } + // If this position is the last one in this row then tell the user that + // moving the button to the next group will create a new group. + if (groupPosition === groupPositionCount && position === positionCount) { + text += '\n'; + text += Drupal.t('This is the last group. Move the button forward to create a new group.'); + } + Drupal.announce(text, 'assertive'); + } + }, + + /** + * Provides help information when a button is clicked. + * + * @param {jQuery.Event} event + * The click event for the button click. + */ + announceButtonHelp: function (event) { + var $link = $(event.currentTarget); + var $button = $link.parent(); + var enabled = $button.closest('.ckeditor-toolbar-active').length > 0; + var message; + + if (enabled) { + message = Drupal.t('The "@name" button is currently enabled.', { + '@name': $link.attr('aria-label') + }); + message += '\n' + Drupal.t('Use the keyboard arrow keys to change the position of this button.'); + message += '\n' + Drupal.t('Press the up arrow key on the top row to disable the button.'); + } + else { + message = Drupal.t('The "@name" button is currently disabled.', { + '@name': $link.attr('aria-label') + }); + message += '\n' + Drupal.t('Use the down arrow key to move this button into the active toolbar.'); + } + Drupal.announce(message); + event.preventDefault(); + }, + + /** + * Provides help information when a separator is clicked. + * + * @param {jQuery.Event} event + * The click event for the separator click. + */ + announceSeparatorHelp: function (event) { + var $link = $(event.currentTarget); + var $button = $link.parent(); + var enabled = $button.closest('.ckeditor-toolbar-active').length > 0; + var message; + + if (enabled) { + message = Drupal.t('This @name is currently enabled.', { + '@name': $link.attr('aria-label') + }); + message += '\n' + Drupal.t('Use the keyboard arrow keys to change the position of this separator.'); + } + else { + message = Drupal.t('Separators are used to visually split individual buttons.'); + message += '\n' + Drupal.t('This @name is currently disabled.', { + '@name': $link.attr('aria-label') + }); + message += '\n' + Drupal.t('Use the down arrow key to move this separator into the active toolbar.'); + message += '\n' + Drupal.t('You may add multiple separators to each button group.'); + } + Drupal.announce(message); + event.preventDefault(); + } + }); + +})(Drupal, Backbone, jQuery); diff --git a/core/modules/ckeditor/js/views/AuralView.js b/core/modules/ckeditor/js/views/AuralView.js index 0659eb2061c9..38b838b14525 100644 --- a/core/modules/ckeditor/js/views/AuralView.js +++ b/core/modules/ckeditor/js/views/AuralView.js @@ -1,18 +1,16 @@ /** - * @file - * A Backbone View that provides the aural view of CKEditor toolbar - * configuration. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/views/AuralView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone, $) { 'use strict'; - Drupal.ckeditor.AuralView = Backbone.View.extend(/** @lends Drupal.ckeditor.AuralView# */{ - - /** - * @type {object} - */ + Drupal.ckeditor.AuralView = Backbone.View.extend({ events: { 'click .ckeditor-buttons a': 'announceButtonHelp', 'click .ckeditor-multiple-buttons a': 'announceSeparatorHelp', @@ -21,52 +19,25 @@ 'focus .ckeditor-toolbar-group': 'onFocus' }, - /** - * Backbone View for CKEditor toolbar configuration; aural UX (output only). - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { - // Announce the button and group positions when the model is no longer - // dirty. + initialize: function initialize() { this.listenTo(this.model, 'change:isDirty', this.announceMove); }, - /** - * Calls announce on buttons and groups when their position is changed. - * - * @param {Drupal.ckeditor.ConfigurationModel} model - * The ckeditor configuration model. - * @param {bool} isDirty - * A model attribute that indicates if the changed toolbar configuration - * has been stored or not. - */ - announceMove: function (model, isDirty) { - // Announce the position of a button or group after the model has been - // updated. + announceMove: function announceMove(model, isDirty) { if (!isDirty) { var item = document.activeElement || null; if (item) { var $item = $(item); if ($item.hasClass('ckeditor-toolbar-group')) { this.announceButtonGroupPosition($item); - } - else if ($item.parent().hasClass('ckeditor-button')) { + } else if ($item.parent().hasClass('ckeditor-button')) { this.announceButtonPosition($item.parent()); } } } }, - /** - * Handles the focus event of elements in the active and available toolbars. - * - * @param {jQuery.Event} event - * The focus event that was triggered. - */ - onFocus: function (event) { + onFocus: function onFocus(event) { event.stopPropagation(); var $originalTarget = $(event.target); @@ -74,19 +45,12 @@ var $parent = $currentTarget.parent(); if ($parent.hasClass('ckeditor-button') || $parent.hasClass('ckeditor-button-separator')) { this.announceButtonPosition($currentTarget.parent()); - } - else if ($originalTarget.attr('role') !== 'button' && $currentTarget.hasClass('ckeditor-toolbar-group')) { + } else if ($originalTarget.attr('role') !== 'button' && $currentTarget.hasClass('ckeditor-toolbar-group')) { this.announceButtonGroupPosition($currentTarget); } }, - /** - * Announces the current position of a button group. - * - * @param {jQuery} $group - * A jQuery set that contains an li element that wraps a group of buttons. - */ - announceButtonGroupPosition: function ($group) { + announceButtonGroupPosition: function announceButtonGroupPosition($group) { var $groups = $group.parent().children(); var $row = $group.closest('.ckeditor-row'); var $rows = $row.parent().children(); @@ -101,8 +65,7 @@ '@row': row, '@rowCount': rowCount }); - // If this position is the first in the last row then tell the user that - // pressing the down arrow key will create a new row. + if (position === 1 && row === rowCount) { text += '\n'; text += Drupal.t('Press the down arrow key to create a new row.'); @@ -110,13 +73,7 @@ Drupal.announce(text, 'assertive'); }, - /** - * Announces current button position. - * - * @param {jQuery} $button - * A jQuery set that contains an li element that wraps a button. - */ - announceButtonPosition: function ($button) { + announceButtonPosition: function announceButtonPosition($button) { var $row = $button.closest('.ckeditor-row'); var $rows = $row.parent().children(); var $buttons = $button.closest('.ckeditor-buttons').children(); @@ -128,12 +85,10 @@ var positionCount = $buttons.length; var row = $rows.index($row) + 1; var rowCount = $rows.not('.placeholder').length; - // The name of the button separator is 'button separator' and its type - // is 'separator', so we do not want to print the type of this item, - // otherwise the UA will speak 'button separator separator'. - var type = ($button.attr('data-drupal-ckeditor-type') === 'separator') ? '' : Drupal.t('button'); + + var type = $button.attr('data-drupal-ckeditor-type') === 'separator' ? '' : Drupal.t('button'); var text; - // The button is located in the available button set. + if ($button.closest('.ckeditor-toolbar-disabled').length > 0) { text = Drupal.t('@name @type.', { '@name': $button.children().attr('aria-label'), @@ -142,41 +97,31 @@ text += '\n' + Drupal.t('Press the down arrow key to activate.'); Drupal.announce(text, 'assertive'); - } - // The button is in the active toolbar. - else if ($group.not('.placeholder').length === 1) { - text = Drupal.t('@name @type in position @position of @positionCount in @groupName button group in row @row of @rowCount.', { - '@name': $button.children().attr('aria-label'), - '@type': type, - '@position': position, - '@positionCount': positionCount, - '@groupName': $group.attr('data-drupal-ckeditor-toolbar-group-name'), - '@row': row, - '@rowCount': rowCount - }); - // If this position is the first in the last row then tell the user that - // pressing the down arrow key will create a new row. - if (groupPosition === 1 && position === 1 && row === rowCount) { - text += '\n'; - text += Drupal.t('Press the down arrow key to create a new button group in a new row.'); - } - // If this position is the last one in this row then tell the user that - // moving the button to the next group will create a new group. - if (groupPosition === groupPositionCount && position === positionCount) { - text += '\n'; - text += Drupal.t('This is the last group. Move the button forward to create a new group.'); + } else if ($group.not('.placeholder').length === 1) { + text = Drupal.t('@name @type in position @position of @positionCount in @groupName button group in row @row of @rowCount.', { + '@name': $button.children().attr('aria-label'), + '@type': type, + '@position': position, + '@positionCount': positionCount, + '@groupName': $group.attr('data-drupal-ckeditor-toolbar-group-name'), + '@row': row, + '@rowCount': rowCount + }); + + if (groupPosition === 1 && position === 1 && row === rowCount) { + text += '\n'; + text += Drupal.t('Press the down arrow key to create a new button group in a new row.'); + } + + if (groupPosition === groupPositionCount && position === positionCount) { + text += '\n'; + text += Drupal.t('This is the last group. Move the button forward to create a new group.'); + } + Drupal.announce(text, 'assertive'); } - Drupal.announce(text, 'assertive'); - } }, - /** - * Provides help information when a button is clicked. - * - * @param {jQuery.Event} event - * The click event for the button click. - */ - announceButtonHelp: function (event) { + announceButtonHelp: function announceButtonHelp(event) { var $link = $(event.currentTarget); var $button = $link.parent(); var enabled = $button.closest('.ckeditor-toolbar-active').length > 0; @@ -188,8 +133,7 @@ }); message += '\n' + Drupal.t('Use the keyboard arrow keys to change the position of this button.'); message += '\n' + Drupal.t('Press the up arrow key on the top row to disable the button.'); - } - else { + } else { message = Drupal.t('The "@name" button is currently disabled.', { '@name': $link.attr('aria-label') }); @@ -199,13 +143,7 @@ event.preventDefault(); }, - /** - * Provides help information when a separator is clicked. - * - * @param {jQuery.Event} event - * The click event for the separator click. - */ - announceSeparatorHelp: function (event) { + announceSeparatorHelp: function announceSeparatorHelp(event) { var $link = $(event.currentTarget); var $button = $link.parent(); var enabled = $button.closest('.ckeditor-toolbar-active').length > 0; @@ -216,8 +154,7 @@ '@name': $link.attr('aria-label') }); message += '\n' + Drupal.t('Use the keyboard arrow keys to change the position of this separator.'); - } - else { + } else { message = Drupal.t('Separators are used to visually split individual buttons.'); message += '\n' + Drupal.t('This @name is currently disabled.', { '@name': $link.attr('aria-label') @@ -229,5 +166,4 @@ event.preventDefault(); } }); - -})(Drupal, Backbone, jQuery); +})(Drupal, Backbone, jQuery); \ No newline at end of file diff --git a/core/modules/ckeditor/js/views/ControllerView.es6.js b/core/modules/ckeditor/js/views/ControllerView.es6.js new file mode 100644 index 000000000000..0f48373a79f8 --- /dev/null +++ b/core/modules/ckeditor/js/views/ControllerView.es6.js @@ -0,0 +1,383 @@ +/** + * @file + * A Backbone View acting as a controller for CKEditor toolbar configuration. + */ + +(function ($, Drupal, Backbone, CKEDITOR, _) { + + 'use strict'; + + Drupal.ckeditor.ControllerView = Backbone.View.extend(/** @lends Drupal.ckeditor.ControllerView# */{ + + /** + * @type {object} + */ + events: {}, + + /** + * Backbone View acting as a controller for CKEditor toolbar configuration. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.getCKEditorFeatures(this.model.get('hiddenEditorConfig'), this.disableFeaturesDisallowedByFilters.bind(this)); + + // Push the active editor configuration to the textarea. + this.model.listenTo(this.model, 'change:activeEditorConfig', this.model.sync); + this.listenTo(this.model, 'change:isDirty', this.parseEditorDOM); + }, + + /** + * Converts the active toolbar DOM structure to an object representation. + * + * @param {Drupal.ckeditor.ConfigurationModel} model + * The state model for the CKEditor configuration. + * @param {bool} isDirty + * Tracks whether the active toolbar DOM structure has been changed. + * isDirty is toggled back to false in this method. + * @param {object} options + * An object that includes: + * @param {bool} [options.broadcast] + * A flag that controls whether a CKEditorToolbarChanged event should be + * fired for configuration changes. + * + * @fires event:CKEditorToolbarChanged + */ + parseEditorDOM: function (model, isDirty, options) { + if (isDirty) { + var currentConfig = this.model.get('activeEditorConfig'); + + // Process the rows. + var rows = []; + this.$el + .find('.ckeditor-active-toolbar-configuration') + .children('.ckeditor-row').each(function () { + var groups = []; + // Process the button groups. + $(this).find('.ckeditor-toolbar-group').each(function () { + var $group = $(this); + var $buttons = $group.find('.ckeditor-button'); + if ($buttons.length) { + var group = { + name: $group.attr('data-drupal-ckeditor-toolbar-group-name'), + items: [] + }; + $group.find('.ckeditor-button, .ckeditor-multiple-button').each(function () { + group.items.push($(this).attr('data-drupal-ckeditor-button-name')); + }); + groups.push(group); + } + }); + if (groups.length) { + rows.push(groups); + } + }); + this.model.set('activeEditorConfig', rows); + // Mark the model as clean. Whether or not the sync to the textfield + // occurs depends on the activeEditorConfig attribute firing a change + // event. The DOM has at least been processed and posted, so as far as + // the model is concerned, it is clean. + this.model.set('isDirty', false); + + // Determine whether we should trigger an event. + if (options.broadcast !== false) { + var prev = this.getButtonList(currentConfig); + var next = this.getButtonList(rows); + if (prev.length !== next.length) { + this.$el + .find('.ckeditor-toolbar-active') + .trigger('CKEditorToolbarChanged', [ + (prev.length < next.length) ? 'added' : 'removed', + _.difference(_.union(prev, next), _.intersection(prev, next))[0] + ]); + } + } + } + }, + + /** + * Asynchronously retrieve the metadata for all available CKEditor features. + * + * In order to get a list of all features needed by CKEditor, we create a + * hidden CKEditor instance, then check the CKEditor's "allowedContent" + * filter settings. Because creating an instance is expensive, a callback + * must be provided that will receive a hash of {@link Drupal.EditorFeature} + * features keyed by feature (button) name. + * + * @param {object} CKEditorConfig + * An object that represents the configuration settings for a CKEditor + * editor component. + * @param {function} callback + * A function to invoke when the instanceReady event is fired by the + * CKEditor object. + */ + getCKEditorFeatures: function (CKEditorConfig, callback) { + var getProperties = function (CKEPropertiesList) { + return (_.isObject(CKEPropertiesList)) ? _.keys(CKEPropertiesList) : []; + }; + + var convertCKERulesToEditorFeature = function (feature, CKEFeatureRules) { + for (var i = 0; i < CKEFeatureRules.length; i++) { + var CKERule = CKEFeatureRules[i]; + var rule = new Drupal.EditorFeatureHTMLRule(); + + // Tags. + var tags = getProperties(CKERule.elements); + rule.required.tags = (CKERule.propertiesOnly) ? [] : tags; + rule.allowed.tags = tags; + // Attributes. + rule.required.attributes = getProperties(CKERule.requiredAttributes); + rule.allowed.attributes = getProperties(CKERule.attributes); + // Styles. + rule.required.styles = getProperties(CKERule.requiredStyles); + rule.allowed.styles = getProperties(CKERule.styles); + // Classes. + rule.required.classes = getProperties(CKERule.requiredClasses); + rule.allowed.classes = getProperties(CKERule.classes); + // Raw. + rule.raw = CKERule; + + feature.addHTMLRule(rule); + } + }; + + // Create hidden CKEditor with all features enabled, retrieve metadata. + // @see \Drupal\ckeditor\Plugin\Editor\CKEditor::buildConfigurationForm(). + var hiddenCKEditorID = 'ckeditor-hidden'; + if (CKEDITOR.instances[hiddenCKEditorID]) { + CKEDITOR.instances[hiddenCKEditorID].destroy(true); + } + // Load external plugins, if any. + var hiddenEditorConfig = this.model.get('hiddenEditorConfig'); + if (hiddenEditorConfig.drupalExternalPlugins) { + var externalPlugins = hiddenEditorConfig.drupalExternalPlugins; + for (var pluginName in externalPlugins) { + if (externalPlugins.hasOwnProperty(pluginName)) { + CKEDITOR.plugins.addExternal(pluginName, externalPlugins[pluginName], ''); + } + } + } + CKEDITOR.inline($('#' + hiddenCKEditorID).get(0), CKEditorConfig); + + // Once the instance is ready, retrieve the allowedContent filter rules + // and convert them to Drupal.EditorFeature objects. + CKEDITOR.once('instanceReady', function (e) { + if (e.editor.name === hiddenCKEditorID) { + // First collect all CKEditor allowedContent rules. + var CKEFeatureRulesMap = {}; + var rules = e.editor.filter.allowedContent; + var rule; + var name; + for (var i = 0; i < rules.length; i++) { + rule = rules[i]; + name = rule.featureName || ':('; + if (!CKEFeatureRulesMap[name]) { + CKEFeatureRulesMap[name] = []; + } + CKEFeatureRulesMap[name].push(rule); + } + + // Now convert these to Drupal.EditorFeature objects. And track which + // buttons are mapped to which features. + // @see getFeatureForButton() + var features = {}; + var buttonsToFeatures = {}; + for (var featureName in CKEFeatureRulesMap) { + if (CKEFeatureRulesMap.hasOwnProperty(featureName)) { + var feature = new Drupal.EditorFeature(featureName); + convertCKERulesToEditorFeature(feature, CKEFeatureRulesMap[featureName]); + features[featureName] = feature; + var command = e.editor.getCommand(featureName); + if (command) { + buttonsToFeatures[command.uiItems[0].name] = featureName; + } + } + } + + callback(features, buttonsToFeatures); + } + }); + }, + + /** + * Retrieves the feature for a given button from featuresMetadata. Returns + * false if the given button is in fact a divider. + * + * @param {string} button + * The name of a CKEditor button. + * + * @return {object} + * The feature metadata object for a button. + */ + getFeatureForButton: function (button) { + // Return false if the button being added is a divider. + if (button === '-') { + return false; + } + + // Get a Drupal.editorFeature object that contains all metadata for + // the feature that was just added or removed. Not every feature has + // such metadata. + var featureName = this.model.get('buttonsToFeatures')[button.toLowerCase()]; + // Features without an associated command do not have a 'feature name' by + // default, so we use the lowercased button name instead. + if (!featureName) { + featureName = button.toLowerCase(); + } + var featuresMetadata = this.model.get('featuresMetadata'); + if (!featuresMetadata[featureName]) { + featuresMetadata[featureName] = new Drupal.EditorFeature(featureName); + this.model.set('featuresMetadata', featuresMetadata); + } + return featuresMetadata[featureName]; + }, + + /** + * Checks buttons against filter settings; disables disallowed buttons. + * + * @param {object} features + * A map of {@link Drupal.EditorFeature} objects. + * @param {object} buttonsToFeatures + * Object containing the button-to-feature mapping. + * + * @see Drupal.ckeditor.ControllerView#getFeatureForButton + */ + disableFeaturesDisallowedByFilters: function (features, buttonsToFeatures) { + this.model.set('featuresMetadata', features); + // Store the button-to-feature mapping. Needs to happen only once, because + // the same buttons continue to have the same features; only the rules for + // specific features may change. + // @see getFeatureForButton() + this.model.set('buttonsToFeatures', buttonsToFeatures); + + // Ensure that toolbar configuration changes are broadcast. + this.broadcastConfigurationChanges(this.$el); + + // Initialization: not all of the default toolbar buttons may be allowed + // by the current filter settings. Remove any of the default toolbar + // buttons that require more permissive filter settings. The remaining + // default toolbar buttons are marked as "added". + var existingButtons = []; + // Loop through each button group after flattening the groups from the + // toolbar row arrays. + var buttonGroups = _.flatten(this.model.get('activeEditorConfig')); + for (var i = 0; i < buttonGroups.length; i++) { + // Pull the button names from each toolbar button group. + var buttons = buttonGroups[i].items; + for (var k = 0; k < buttons.length; k++) { + existingButtons.push(buttons[k]); + } + } + // Remove duplicate buttons. + existingButtons = _.unique(existingButtons); + // Prepare the active toolbar and available-button toolbars. + for (var n = 0; n < existingButtons.length; n++) { + var button = existingButtons[n]; + var feature = this.getFeatureForButton(button); + // Skip dividers. + if (feature === false) { + continue; + } + + if (Drupal.editorConfiguration.featureIsAllowedByFilters(feature)) { + // Existing toolbar buttons are in fact "added features". + this.$el.find('.ckeditor-toolbar-active').trigger('CKEditorToolbarChanged', ['added', existingButtons[n]]); + } + else { + // Move the button element from the active the active toolbar to the + // list of available buttons. + $('.ckeditor-toolbar-active li[data-drupal-ckeditor-button-name="' + button + '"]') + .detach() + .appendTo('.ckeditor-toolbar-disabled > .ckeditor-toolbar-available > ul'); + // Update the toolbar value field. + this.model.set({isDirty: true}, {broadcast: false}); + } + } + }, + + /** + * Sets up broadcasting of CKEditor toolbar configuration changes. + * + * @param {jQuery} $ckeditorToolbar + * The active toolbar DOM element wrapped in jQuery. + */ + broadcastConfigurationChanges: function ($ckeditorToolbar) { + var view = this; + var hiddenEditorConfig = this.model.get('hiddenEditorConfig'); + var getFeatureForButton = this.getFeatureForButton.bind(this); + var getCKEditorFeatures = this.getCKEditorFeatures.bind(this); + $ckeditorToolbar + .find('.ckeditor-toolbar-active') + // Listen for CKEditor toolbar configuration changes. When a button is + // added/removed, call an appropriate Drupal.editorConfiguration method. + .on('CKEditorToolbarChanged.ckeditorAdmin', function (event, action, button) { + var feature = getFeatureForButton(button); + + // Early-return if the button being added is a divider. + if (feature === false) { + return; + } + + // Trigger a standardized text editor configuration event to indicate + // whether a feature was added or removed, so that filters can react. + var configEvent = (action === 'added') ? 'addedFeature' : 'removedFeature'; + Drupal.editorConfiguration[configEvent](feature); + }) + // Listen for CKEditor plugin settings changes. When a plugin setting is + // changed, rebuild the CKEditor features metadata. + .on('CKEditorPluginSettingsChanged.ckeditorAdmin', function (event, settingsChanges) { + // Update hidden CKEditor configuration. + for (var key in settingsChanges) { + if (settingsChanges.hasOwnProperty(key)) { + hiddenEditorConfig[key] = settingsChanges[key]; + } + } + + // Retrieve features for the updated hidden CKEditor configuration. + getCKEditorFeatures(hiddenEditorConfig, function (features) { + // Trigger a standardized text editor configuration event for each + // feature that was modified by the configuration changes. + var featuresMetadata = view.model.get('featuresMetadata'); + for (var name in features) { + if (features.hasOwnProperty(name)) { + var feature = features[name]; + if (featuresMetadata.hasOwnProperty(name) && !_.isEqual(featuresMetadata[name], feature)) { + Drupal.editorConfiguration.modifiedFeature(feature); + } + } + } + // Update the CKEditor features metadata. + view.model.set('featuresMetadata', features); + }); + }); + }, + + /** + * Returns the list of buttons from an editor configuration. + * + * @param {object} config + * A CKEditor configuration object. + * + * @return {Array} + * A list of buttons in the CKEditor configuration. + */ + getButtonList: function (config) { + var buttons = []; + // Remove the rows. + config = _.flatten(config); + + // Loop through the button groups and pull out the buttons. + config.forEach(function (group) { + group.items.forEach(function (button) { + buttons.push(button); + }); + }); + + // Remove the dividing elements if any. + return _.without(buttons, '-'); + } + }); + +})(jQuery, Drupal, Backbone, CKEDITOR, _); diff --git a/core/modules/ckeditor/js/views/ControllerView.js b/core/modules/ckeditor/js/views/ControllerView.js index 0f48373a79f8..ab1e8bff8305 100644 --- a/core/modules/ckeditor/js/views/ControllerView.js +++ b/core/modules/ckeditor/js/views/ControllerView.js @@ -1,155 +1,99 @@ /** - * @file - * A Backbone View acting as a controller for CKEditor toolbar configuration. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/views/ControllerView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, Backbone, CKEDITOR, _) { 'use strict'; - Drupal.ckeditor.ControllerView = Backbone.View.extend(/** @lends Drupal.ckeditor.ControllerView# */{ - - /** - * @type {object} - */ + Drupal.ckeditor.ControllerView = Backbone.View.extend({ events: {}, - /** - * Backbone View acting as a controller for CKEditor toolbar configuration. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + initialize: function initialize() { this.getCKEditorFeatures(this.model.get('hiddenEditorConfig'), this.disableFeaturesDisallowedByFilters.bind(this)); - // Push the active editor configuration to the textarea. this.model.listenTo(this.model, 'change:activeEditorConfig', this.model.sync); this.listenTo(this.model, 'change:isDirty', this.parseEditorDOM); }, - /** - * Converts the active toolbar DOM structure to an object representation. - * - * @param {Drupal.ckeditor.ConfigurationModel} model - * The state model for the CKEditor configuration. - * @param {bool} isDirty - * Tracks whether the active toolbar DOM structure has been changed. - * isDirty is toggled back to false in this method. - * @param {object} options - * An object that includes: - * @param {bool} [options.broadcast] - * A flag that controls whether a CKEditorToolbarChanged event should be - * fired for configuration changes. - * - * @fires event:CKEditorToolbarChanged - */ - parseEditorDOM: function (model, isDirty, options) { + parseEditorDOM: function parseEditorDOM(model, isDirty, options) { if (isDirty) { var currentConfig = this.model.get('activeEditorConfig'); - // Process the rows. var rows = []; - this.$el - .find('.ckeditor-active-toolbar-configuration') - .children('.ckeditor-row').each(function () { - var groups = []; - // Process the button groups. - $(this).find('.ckeditor-toolbar-group').each(function () { - var $group = $(this); - var $buttons = $group.find('.ckeditor-button'); - if ($buttons.length) { - var group = { - name: $group.attr('data-drupal-ckeditor-toolbar-group-name'), - items: [] - }; - $group.find('.ckeditor-button, .ckeditor-multiple-button').each(function () { - group.items.push($(this).attr('data-drupal-ckeditor-button-name')); - }); - groups.push(group); - } - }); - if (groups.length) { - rows.push(groups); + this.$el.find('.ckeditor-active-toolbar-configuration').children('.ckeditor-row').each(function () { + var groups = []; + + $(this).find('.ckeditor-toolbar-group').each(function () { + var $group = $(this); + var $buttons = $group.find('.ckeditor-button'); + if ($buttons.length) { + var group = { + name: $group.attr('data-drupal-ckeditor-toolbar-group-name'), + items: [] + }; + $group.find('.ckeditor-button, .ckeditor-multiple-button').each(function () { + group.items.push($(this).attr('data-drupal-ckeditor-button-name')); + }); + groups.push(group); } }); + if (groups.length) { + rows.push(groups); + } + }); this.model.set('activeEditorConfig', rows); - // Mark the model as clean. Whether or not the sync to the textfield - // occurs depends on the activeEditorConfig attribute firing a change - // event. The DOM has at least been processed and posted, so as far as - // the model is concerned, it is clean. + this.model.set('isDirty', false); - // Determine whether we should trigger an event. if (options.broadcast !== false) { var prev = this.getButtonList(currentConfig); var next = this.getButtonList(rows); if (prev.length !== next.length) { - this.$el - .find('.ckeditor-toolbar-active') - .trigger('CKEditorToolbarChanged', [ - (prev.length < next.length) ? 'added' : 'removed', - _.difference(_.union(prev, next), _.intersection(prev, next))[0] - ]); + this.$el.find('.ckeditor-toolbar-active').trigger('CKEditorToolbarChanged', [prev.length < next.length ? 'added' : 'removed', _.difference(_.union(prev, next), _.intersection(prev, next))[0]]); } } } }, - /** - * Asynchronously retrieve the metadata for all available CKEditor features. - * - * In order to get a list of all features needed by CKEditor, we create a - * hidden CKEditor instance, then check the CKEditor's "allowedContent" - * filter settings. Because creating an instance is expensive, a callback - * must be provided that will receive a hash of {@link Drupal.EditorFeature} - * features keyed by feature (button) name. - * - * @param {object} CKEditorConfig - * An object that represents the configuration settings for a CKEditor - * editor component. - * @param {function} callback - * A function to invoke when the instanceReady event is fired by the - * CKEditor object. - */ - getCKEditorFeatures: function (CKEditorConfig, callback) { - var getProperties = function (CKEPropertiesList) { - return (_.isObject(CKEPropertiesList)) ? _.keys(CKEPropertiesList) : []; + getCKEditorFeatures: function getCKEditorFeatures(CKEditorConfig, callback) { + var getProperties = function getProperties(CKEPropertiesList) { + return _.isObject(CKEPropertiesList) ? _.keys(CKEPropertiesList) : []; }; - var convertCKERulesToEditorFeature = function (feature, CKEFeatureRules) { + var convertCKERulesToEditorFeature = function convertCKERulesToEditorFeature(feature, CKEFeatureRules) { for (var i = 0; i < CKEFeatureRules.length; i++) { var CKERule = CKEFeatureRules[i]; var rule = new Drupal.EditorFeatureHTMLRule(); - // Tags. var tags = getProperties(CKERule.elements); - rule.required.tags = (CKERule.propertiesOnly) ? [] : tags; + rule.required.tags = CKERule.propertiesOnly ? [] : tags; rule.allowed.tags = tags; - // Attributes. + rule.required.attributes = getProperties(CKERule.requiredAttributes); rule.allowed.attributes = getProperties(CKERule.attributes); - // Styles. + rule.required.styles = getProperties(CKERule.requiredStyles); rule.allowed.styles = getProperties(CKERule.styles); - // Classes. + rule.required.classes = getProperties(CKERule.requiredClasses); rule.allowed.classes = getProperties(CKERule.classes); - // Raw. + rule.raw = CKERule; feature.addHTMLRule(rule); } }; - // Create hidden CKEditor with all features enabled, retrieve metadata. - // @see \Drupal\ckeditor\Plugin\Editor\CKEditor::buildConfigurationForm(). var hiddenCKEditorID = 'ckeditor-hidden'; if (CKEDITOR.instances[hiddenCKEditorID]) { CKEDITOR.instances[hiddenCKEditorID].destroy(true); } - // Load external plugins, if any. + var hiddenEditorConfig = this.model.get('hiddenEditorConfig'); if (hiddenEditorConfig.drupalExternalPlugins) { var externalPlugins = hiddenEditorConfig.drupalExternalPlugins; @@ -161,11 +105,8 @@ } CKEDITOR.inline($('#' + hiddenCKEditorID).get(0), CKEditorConfig); - // Once the instance is ready, retrieve the allowedContent filter rules - // and convert them to Drupal.EditorFeature objects. CKEDITOR.once('instanceReady', function (e) { if (e.editor.name === hiddenCKEditorID) { - // First collect all CKEditor allowedContent rules. var CKEFeatureRulesMap = {}; var rules = e.editor.filter.allowedContent; var rule; @@ -179,9 +120,6 @@ CKEFeatureRulesMap[name].push(rule); } - // Now convert these to Drupal.EditorFeature objects. And track which - // buttons are mapped to which features. - // @see getFeatureForButton() var features = {}; var buttonsToFeatures = {}; for (var featureName in CKEFeatureRulesMap) { @@ -201,28 +139,13 @@ }); }, - /** - * Retrieves the feature for a given button from featuresMetadata. Returns - * false if the given button is in fact a divider. - * - * @param {string} button - * The name of a CKEditor button. - * - * @return {object} - * The feature metadata object for a button. - */ - getFeatureForButton: function (button) { - // Return false if the button being added is a divider. + getFeatureForButton: function getFeatureForButton(button) { if (button === '-') { return false; } - // Get a Drupal.editorFeature object that contains all metadata for - // the feature that was just added or removed. Not every feature has - // such metadata. var featureName = this.model.get('buttonsToFeatures')[button.toLowerCase()]; - // Features without an associated command do not have a 'feature name' by - // default, so we use the lowercased button name instead. + if (!featureName) { featureName = button.toLowerCase(); } @@ -234,150 +157,92 @@ return featuresMetadata[featureName]; }, - /** - * Checks buttons against filter settings; disables disallowed buttons. - * - * @param {object} features - * A map of {@link Drupal.EditorFeature} objects. - * @param {object} buttonsToFeatures - * Object containing the button-to-feature mapping. - * - * @see Drupal.ckeditor.ControllerView#getFeatureForButton - */ - disableFeaturesDisallowedByFilters: function (features, buttonsToFeatures) { + disableFeaturesDisallowedByFilters: function disableFeaturesDisallowedByFilters(features, buttonsToFeatures) { this.model.set('featuresMetadata', features); - // Store the button-to-feature mapping. Needs to happen only once, because - // the same buttons continue to have the same features; only the rules for - // specific features may change. - // @see getFeatureForButton() + this.model.set('buttonsToFeatures', buttonsToFeatures); - // Ensure that toolbar configuration changes are broadcast. this.broadcastConfigurationChanges(this.$el); - // Initialization: not all of the default toolbar buttons may be allowed - // by the current filter settings. Remove any of the default toolbar - // buttons that require more permissive filter settings. The remaining - // default toolbar buttons are marked as "added". var existingButtons = []; - // Loop through each button group after flattening the groups from the - // toolbar row arrays. + var buttonGroups = _.flatten(this.model.get('activeEditorConfig')); for (var i = 0; i < buttonGroups.length; i++) { - // Pull the button names from each toolbar button group. var buttons = buttonGroups[i].items; for (var k = 0; k < buttons.length; k++) { existingButtons.push(buttons[k]); } } - // Remove duplicate buttons. + existingButtons = _.unique(existingButtons); - // Prepare the active toolbar and available-button toolbars. + for (var n = 0; n < existingButtons.length; n++) { var button = existingButtons[n]; var feature = this.getFeatureForButton(button); - // Skip dividers. + if (feature === false) { continue; } if (Drupal.editorConfiguration.featureIsAllowedByFilters(feature)) { - // Existing toolbar buttons are in fact "added features". this.$el.find('.ckeditor-toolbar-active').trigger('CKEditorToolbarChanged', ['added', existingButtons[n]]); - } - else { - // Move the button element from the active the active toolbar to the - // list of available buttons. - $('.ckeditor-toolbar-active li[data-drupal-ckeditor-button-name="' + button + '"]') - .detach() - .appendTo('.ckeditor-toolbar-disabled > .ckeditor-toolbar-available > ul'); - // Update the toolbar value field. - this.model.set({isDirty: true}, {broadcast: false}); + } else { + $('.ckeditor-toolbar-active li[data-drupal-ckeditor-button-name="' + button + '"]').detach().appendTo('.ckeditor-toolbar-disabled > .ckeditor-toolbar-available > ul'); + + this.model.set({ isDirty: true }, { broadcast: false }); } } }, - /** - * Sets up broadcasting of CKEditor toolbar configuration changes. - * - * @param {jQuery} $ckeditorToolbar - * The active toolbar DOM element wrapped in jQuery. - */ - broadcastConfigurationChanges: function ($ckeditorToolbar) { + broadcastConfigurationChanges: function broadcastConfigurationChanges($ckeditorToolbar) { var view = this; var hiddenEditorConfig = this.model.get('hiddenEditorConfig'); var getFeatureForButton = this.getFeatureForButton.bind(this); var getCKEditorFeatures = this.getCKEditorFeatures.bind(this); - $ckeditorToolbar - .find('.ckeditor-toolbar-active') - // Listen for CKEditor toolbar configuration changes. When a button is - // added/removed, call an appropriate Drupal.editorConfiguration method. - .on('CKEditorToolbarChanged.ckeditorAdmin', function (event, action, button) { - var feature = getFeatureForButton(button); - - // Early-return if the button being added is a divider. - if (feature === false) { - return; - } + $ckeditorToolbar.find('.ckeditor-toolbar-active').on('CKEditorToolbarChanged.ckeditorAdmin', function (event, action, button) { + var feature = getFeatureForButton(button); - // Trigger a standardized text editor configuration event to indicate - // whether a feature was added or removed, so that filters can react. - var configEvent = (action === 'added') ? 'addedFeature' : 'removedFeature'; - Drupal.editorConfiguration[configEvent](feature); - }) - // Listen for CKEditor plugin settings changes. When a plugin setting is - // changed, rebuild the CKEditor features metadata. - .on('CKEditorPluginSettingsChanged.ckeditorAdmin', function (event, settingsChanges) { - // Update hidden CKEditor configuration. - for (var key in settingsChanges) { - if (settingsChanges.hasOwnProperty(key)) { - hiddenEditorConfig[key] = settingsChanges[key]; - } + if (feature === false) { + return; + } + + var configEvent = action === 'added' ? 'addedFeature' : 'removedFeature'; + Drupal.editorConfiguration[configEvent](feature); + }).on('CKEditorPluginSettingsChanged.ckeditorAdmin', function (event, settingsChanges) { + for (var key in settingsChanges) { + if (settingsChanges.hasOwnProperty(key)) { + hiddenEditorConfig[key] = settingsChanges[key]; } + } - // Retrieve features for the updated hidden CKEditor configuration. - getCKEditorFeatures(hiddenEditorConfig, function (features) { - // Trigger a standardized text editor configuration event for each - // feature that was modified by the configuration changes. - var featuresMetadata = view.model.get('featuresMetadata'); - for (var name in features) { - if (features.hasOwnProperty(name)) { - var feature = features[name]; - if (featuresMetadata.hasOwnProperty(name) && !_.isEqual(featuresMetadata[name], feature)) { - Drupal.editorConfiguration.modifiedFeature(feature); - } + getCKEditorFeatures(hiddenEditorConfig, function (features) { + var featuresMetadata = view.model.get('featuresMetadata'); + for (var name in features) { + if (features.hasOwnProperty(name)) { + var feature = features[name]; + if (featuresMetadata.hasOwnProperty(name) && !_.isEqual(featuresMetadata[name], feature)) { + Drupal.editorConfiguration.modifiedFeature(feature); } } - // Update the CKEditor features metadata. - view.model.set('featuresMetadata', features); - }); + } + + view.model.set('featuresMetadata', features); }); + }); }, - /** - * Returns the list of buttons from an editor configuration. - * - * @param {object} config - * A CKEditor configuration object. - * - * @return {Array} - * A list of buttons in the CKEditor configuration. - */ - getButtonList: function (config) { + getButtonList: function getButtonList(config) { var buttons = []; - // Remove the rows. + config = _.flatten(config); - // Loop through the button groups and pull out the buttons. config.forEach(function (group) { group.items.forEach(function (button) { buttons.push(button); }); }); - // Remove the dividing elements if any. return _.without(buttons, '-'); } }); - -})(jQuery, Drupal, Backbone, CKEDITOR, _); +})(jQuery, Drupal, Backbone, CKEDITOR, _); \ No newline at end of file diff --git a/core/modules/ckeditor/js/views/KeyboardView.es6.js b/core/modules/ckeditor/js/views/KeyboardView.es6.js new file mode 100644 index 000000000000..f44764e3aa83 --- /dev/null +++ b/core/modules/ckeditor/js/views/KeyboardView.es6.js @@ -0,0 +1,266 @@ +/** + * @file + * Backbone View providing the aural view of CKEditor keyboard UX configuration. + */ + +(function ($, Drupal, Backbone, _) { + + 'use strict'; + + Drupal.ckeditor.KeyboardView = Backbone.View.extend(/** @lends Drupal.ckeditor.KeyboardView# */{ + + /** + * Backbone View for CKEditor toolbar configuration; keyboard UX. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + // Add keyboard arrow support. + this.$el.on('keydown.ckeditor', '.ckeditor-buttons a, .ckeditor-multiple-buttons a', this.onPressButton.bind(this)); + this.$el.on('keydown.ckeditor', '[data-drupal-ckeditor-type="group"]', this.onPressGroup.bind(this)); + }, + + /** + * @inheritdoc + */ + render: function () { + }, + + /** + * Handles keypresses on a CKEditor configuration button. + * + * @param {jQuery.Event} event + * The keypress event triggered. + */ + onPressButton: function (event) { + var upDownKeys = [ + 38, // Up arrow. + 63232, // Safari up arrow. + 40, // Down arrow. + 63233 // Safari down arrow. + ]; + var leftRightKeys = [ + 37, // Left arrow. + 63234, // Safari left arrow. + 39, // Right arrow. + 63235 // Safari right arrow. + ]; + + // Respond to an enter key press. Prevent the bubbling of the enter key + // press to the button group parent element. + if (event.keyCode === 13) { + event.stopPropagation(); + } + + // Only take action when a direction key is pressed. + if (_.indexOf(_.union(upDownKeys, leftRightKeys), event.keyCode) > -1) { + var view = this; + var $target = $(event.currentTarget); + var $button = $target.parent(); + var $container = $button.parent(); + var $group = $button.closest('.ckeditor-toolbar-group'); + var $row; + var containerType = $container.data('drupal-ckeditor-button-sorting'); + var $availableButtons = this.$el.find('[data-drupal-ckeditor-button-sorting="source"]'); + var $activeButtons = this.$el.find('.ckeditor-toolbar-active'); + // The current location of the button, just in case it needs to be put + // back. + var $originalGroup = $group; + var dir; + + // Move available buttons between their container and the active + // toolbar. + if (containerType === 'source') { + // Move the button to the active toolbar configuration when the down + // or up keys are pressed. + if (_.indexOf([40, 63233], event.keyCode) > -1) { + // Move the button to the first row, first button group index + // position. + $activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button); + } + } + else if (containerType === 'target') { + // Move buttons between sibling buttons in a group and between groups. + if (_.indexOf(leftRightKeys, event.keyCode) > -1) { + // Move left. + var $siblings = $container.children(); + var index = $siblings.index($button); + if (_.indexOf([37, 63234], event.keyCode) > -1) { + // Move between sibling buttons. + if (index > 0) { + $button.insertBefore($container.children().eq(index - 1)); + } + // Move between button groups and rows. + else { + // Move between button groups. + $group = $container.parent().prev(); + if ($group.length > 0) { + $group.find('.ckeditor-toolbar-group-buttons').append($button); + } + // Wrap between rows. + else { + $container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-group').not('.placeholder').find('.ckeditor-toolbar-group-buttons').eq(-1).append($button); + } + } + } + // Move right. + else if (_.indexOf([39, 63235], event.keyCode) > -1) { + // Move between sibling buttons. + if (index < ($siblings.length - 1)) { + $button.insertAfter($container.children().eq(index + 1)); + } + // Move between button groups. Moving right at the end of a row + // will create a new group. + else { + $container.parent().next().find('.ckeditor-toolbar-group-buttons').prepend($button); + } + } + } + // Move buttons between rows and the available button set. + else if (_.indexOf(upDownKeys, event.keyCode) > -1) { + dir = (_.indexOf([38, 63232], event.keyCode) > -1) ? 'prev' : 'next'; + $row = $container.closest('.ckeditor-row')[dir](); + // Move the button back into the available button set. + if (dir === 'prev' && $row.length === 0) { + // If this is a divider, just destroy it. + if ($button.data('drupal-ckeditor-type') === 'separator') { + $button + .off() + .remove(); + // Focus on the first button in the active toolbar. + $activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).children().eq(0).children().trigger('focus'); + } + // Otherwise, move it. + else { + $availableButtons.prepend($button); + } + } + else { + $row.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button); + } + } + } + // Move dividers between their container and the active toolbar. + else if (containerType === 'dividers') { + // Move the button to the active toolbar configuration when the down + // or up keys are pressed. + if (_.indexOf([40, 63233], event.keyCode) > -1) { + // Move the button to the first row, first button group index + // position. + $button = $button.clone(true); + $activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button); + $target = $button.children(); + } + } + + view = this; + // Attempt to move the button to the new toolbar position. + Drupal.ckeditor.registerButtonMove(this, $button, function (result) { + + // Put the button back if the registration failed. + // If the button was in a row, then it was in the active toolbar + // configuration. The button was probably placed in a new group, but + // that action was canceled. + if (!result && $originalGroup) { + $originalGroup.find('.ckeditor-buttons').append($button); + } + // Otherwise refresh the sortables to acknowledge the new button + // positions. + else { + view.$el.find('.ui-sortable').sortable('refresh'); + } + // Refocus the target button so that the user can continue from a + // known place. + $target.trigger('focus'); + }); + + event.preventDefault(); + event.stopPropagation(); + } + }, + + /** + * Handles keypresses on a CKEditor configuration group. + * + * @param {jQuery.Event} event + * The keypress event triggered. + */ + onPressGroup: function (event) { + var upDownKeys = [ + 38, // Up arrow. + 63232, // Safari up arrow. + 40, // Down arrow. + 63233 // Safari down arrow. + ]; + var leftRightKeys = [ + 37, // Left arrow. + 63234, // Safari left arrow. + 39, // Right arrow. + 63235 // Safari right arrow. + ]; + + // Respond to an enter key press. + if (event.keyCode === 13) { + var view = this; + // Open the group renaming dialog in the next evaluation cycle so that + // this event can be cancelled and the bubbling wiped out. Otherwise, + // Firefox has issues because the page focus is shifted to the dialog + // along with the keydown event. + window.setTimeout(function () { + Drupal.ckeditor.openGroupNameDialog(view, $(event.currentTarget)); + }, 0); + event.preventDefault(); + event.stopPropagation(); + } + + // Respond to direction key presses. + if (_.indexOf(_.union(upDownKeys, leftRightKeys), event.keyCode) > -1) { + var $group = $(event.currentTarget); + var $container = $group.parent(); + var $siblings = $container.children(); + var index; + var dir; + // Move groups between sibling groups. + if (_.indexOf(leftRightKeys, event.keyCode) > -1) { + index = $siblings.index($group); + // Move left between sibling groups. + if ((_.indexOf([37, 63234], event.keyCode) > -1)) { + if (index > 0) { + $group.insertBefore($siblings.eq(index - 1)); + } + // Wrap between rows. Insert the group before the placeholder group + // at the end of the previous row. + else { + $group.insertBefore($container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-groups').children().eq(-1)); + } + } + // Move right between sibling groups. + else if (_.indexOf([39, 63235], event.keyCode) > -1) { + // Move to the right if the next group is not a placeholder. + if (!$siblings.eq(index + 1).hasClass('placeholder')) { + $group.insertAfter($container.children().eq(index + 1)); + } + // Wrap group between rows. + else { + $container.closest('.ckeditor-row').next().find('.ckeditor-toolbar-groups').prepend($group); + } + } + + } + // Move groups between rows. + else if (_.indexOf(upDownKeys, event.keyCode) > -1) { + dir = (_.indexOf([38, 63232], event.keyCode) > -1) ? 'prev' : 'next'; + $group.closest('.ckeditor-row')[dir]().find('.ckeditor-toolbar-groups').eq(0).prepend($group); + } + + Drupal.ckeditor.registerGroupMove(this, $group); + $group.trigger('focus'); + event.preventDefault(); + event.stopPropagation(); + } + } + }); + +})(jQuery, Drupal, Backbone, _); diff --git a/core/modules/ckeditor/js/views/KeyboardView.js b/core/modules/ckeditor/js/views/KeyboardView.js index f44764e3aa83..9c124048f585 100644 --- a/core/modules/ckeditor/js/views/KeyboardView.js +++ b/core/modules/ckeditor/js/views/KeyboardView.js @@ -1,60 +1,31 @@ /** - * @file - * Backbone View providing the aural view of CKEditor keyboard UX configuration. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/views/KeyboardView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, Backbone, _) { 'use strict'; - Drupal.ckeditor.KeyboardView = Backbone.View.extend(/** @lends Drupal.ckeditor.KeyboardView# */{ - - /** - * Backbone View for CKEditor toolbar configuration; keyboard UX. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { - // Add keyboard arrow support. + Drupal.ckeditor.KeyboardView = Backbone.View.extend({ + initialize: function initialize() { this.$el.on('keydown.ckeditor', '.ckeditor-buttons a, .ckeditor-multiple-buttons a', this.onPressButton.bind(this)); this.$el.on('keydown.ckeditor', '[data-drupal-ckeditor-type="group"]', this.onPressGroup.bind(this)); }, - /** - * @inheritdoc - */ - render: function () { - }, + render: function render() {}, - /** - * Handles keypresses on a CKEditor configuration button. - * - * @param {jQuery.Event} event - * The keypress event triggered. - */ - onPressButton: function (event) { - var upDownKeys = [ - 38, // Up arrow. - 63232, // Safari up arrow. - 40, // Down arrow. - 63233 // Safari down arrow. - ]; - var leftRightKeys = [ - 37, // Left arrow. - 63234, // Safari left arrow. - 39, // Right arrow. - 63235 // Safari right arrow. - ]; + onPressButton: function onPressButton(event) { + var upDownKeys = [38, 63232, 40, 63233]; + var leftRightKeys = [37, 63234, 39, 63235]; - // Respond to an enter key press. Prevent the bubbling of the enter key - // press to the button group parent element. if (event.keyCode === 13) { event.stopPropagation(); } - // Only take action when a direction key is pressed. if (_.indexOf(_.union(upDownKeys, leftRightKeys), event.keyCode) > -1) { var view = this; var $target = $(event.currentTarget); @@ -65,114 +36,69 @@ var containerType = $container.data('drupal-ckeditor-button-sorting'); var $availableButtons = this.$el.find('[data-drupal-ckeditor-button-sorting="source"]'); var $activeButtons = this.$el.find('.ckeditor-toolbar-active'); - // The current location of the button, just in case it needs to be put - // back. + var $originalGroup = $group; var dir; - // Move available buttons between their container and the active - // toolbar. if (containerType === 'source') { - // Move the button to the active toolbar configuration when the down - // or up keys are pressed. if (_.indexOf([40, 63233], event.keyCode) > -1) { - // Move the button to the first row, first button group index - // position. $activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button); } - } - else if (containerType === 'target') { - // Move buttons between sibling buttons in a group and between groups. + } else if (containerType === 'target') { if (_.indexOf(leftRightKeys, event.keyCode) > -1) { - // Move left. var $siblings = $container.children(); var index = $siblings.index($button); if (_.indexOf([37, 63234], event.keyCode) > -1) { - // Move between sibling buttons. if (index > 0) { $button.insertBefore($container.children().eq(index - 1)); - } - // Move between button groups and rows. - else { - // Move between button groups. - $group = $container.parent().prev(); - if ($group.length > 0) { - $group.find('.ckeditor-toolbar-group-buttons').append($button); + } else { + $group = $container.parent().prev(); + if ($group.length > 0) { + $group.find('.ckeditor-toolbar-group-buttons').append($button); + } else { + $container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-group').not('.placeholder').find('.ckeditor-toolbar-group-buttons').eq(-1).append($button); + } } - // Wrap between rows. - else { - $container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-group').not('.placeholder').find('.ckeditor-toolbar-group-buttons').eq(-1).append($button); - } - } - } - // Move right. - else if (_.indexOf([39, 63235], event.keyCode) > -1) { - // Move between sibling buttons. - if (index < ($siblings.length - 1)) { - $button.insertAfter($container.children().eq(index + 1)); - } - // Move between button groups. Moving right at the end of a row - // will create a new group. - else { - $container.parent().next().find('.ckeditor-toolbar-group-buttons').prepend($button); + } else if (_.indexOf([39, 63235], event.keyCode) > -1) { + if (index < $siblings.length - 1) { + $button.insertAfter($container.children().eq(index + 1)); + } else { + $container.parent().next().find('.ckeditor-toolbar-group-buttons').prepend($button); + } } - } - } - // Move buttons between rows and the available button set. - else if (_.indexOf(upDownKeys, event.keyCode) > -1) { - dir = (_.indexOf([38, 63232], event.keyCode) > -1) ? 'prev' : 'next'; - $row = $container.closest('.ckeditor-row')[dir](); - // Move the button back into the available button set. - if (dir === 'prev' && $row.length === 0) { - // If this is a divider, just destroy it. - if ($button.data('drupal-ckeditor-type') === 'separator') { - $button - .off() - .remove(); - // Focus on the first button in the active toolbar. - $activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).children().eq(0).children().trigger('focus'); - } - // Otherwise, move it. - else { - $availableButtons.prepend($button); + } else if (_.indexOf(upDownKeys, event.keyCode) > -1) { + dir = _.indexOf([38, 63232], event.keyCode) > -1 ? 'prev' : 'next'; + $row = $container.closest('.ckeditor-row')[dir](); + + if (dir === 'prev' && $row.length === 0) { + if ($button.data('drupal-ckeditor-type') === 'separator') { + $button.off().remove(); + + $activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).children().eq(0).children().trigger('focus'); + } else { + $availableButtons.prepend($button); + } + } else { + $row.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button); } } - else { - $row.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button); + } else if (containerType === 'dividers') { + if (_.indexOf([40, 63233], event.keyCode) > -1) { + $button = $button.clone(true); + $activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button); + $target = $button.children(); } } - } - // Move dividers between their container and the active toolbar. - else if (containerType === 'dividers') { - // Move the button to the active toolbar configuration when the down - // or up keys are pressed. - if (_.indexOf([40, 63233], event.keyCode) > -1) { - // Move the button to the first row, first button group index - // position. - $button = $button.clone(true); - $activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button); - $target = $button.children(); - } - } view = this; - // Attempt to move the button to the new toolbar position. - Drupal.ckeditor.registerButtonMove(this, $button, function (result) { - // Put the button back if the registration failed. - // If the button was in a row, then it was in the active toolbar - // configuration. The button was probably placed in a new group, but - // that action was canceled. + Drupal.ckeditor.registerButtonMove(this, $button, function (result) { if (!result && $originalGroup) { $originalGroup.find('.ckeditor-buttons').append($button); - } - // Otherwise refresh the sortables to acknowledge the new button - // positions. - else { - view.$el.find('.ui-sortable').sortable('refresh'); - } - // Refocus the target button so that the user can continue from a - // known place. + } else { + view.$el.find('.ui-sortable').sortable('refresh'); + } + $target.trigger('focus'); }); @@ -181,33 +107,13 @@ } }, - /** - * Handles keypresses on a CKEditor configuration group. - * - * @param {jQuery.Event} event - * The keypress event triggered. - */ - onPressGroup: function (event) { - var upDownKeys = [ - 38, // Up arrow. - 63232, // Safari up arrow. - 40, // Down arrow. - 63233 // Safari down arrow. - ]; - var leftRightKeys = [ - 37, // Left arrow. - 63234, // Safari left arrow. - 39, // Right arrow. - 63235 // Safari right arrow. - ]; + onPressGroup: function onPressGroup(event) { + var upDownKeys = [38, 63232, 40, 63233]; + var leftRightKeys = [37, 63234, 39, 63235]; - // Respond to an enter key press. if (event.keyCode === 13) { var view = this; - // Open the group renaming dialog in the next evaluation cycle so that - // this event can be cancelled and the bubbling wiped out. Otherwise, - // Firefox has issues because the page focus is shifted to the dialog - // along with the keydown event. + window.setTimeout(function () { Drupal.ckeditor.openGroupNameDialog(view, $(event.currentTarget)); }, 0); @@ -215,46 +121,34 @@ event.stopPropagation(); } - // Respond to direction key presses. if (_.indexOf(_.union(upDownKeys, leftRightKeys), event.keyCode) > -1) { var $group = $(event.currentTarget); var $container = $group.parent(); var $siblings = $container.children(); var index; var dir; - // Move groups between sibling groups. + if (_.indexOf(leftRightKeys, event.keyCode) > -1) { index = $siblings.index($group); - // Move left between sibling groups. - if ((_.indexOf([37, 63234], event.keyCode) > -1)) { + + if (_.indexOf([37, 63234], event.keyCode) > -1) { if (index > 0) { $group.insertBefore($siblings.eq(index - 1)); + } else { + $group.insertBefore($container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-groups').children().eq(-1)); + } + } else if (_.indexOf([39, 63235], event.keyCode) > -1) { + if (!$siblings.eq(index + 1).hasClass('placeholder')) { + $group.insertAfter($container.children().eq(index + 1)); + } else { + $container.closest('.ckeditor-row').next().find('.ckeditor-toolbar-groups').prepend($group); + } } - // Wrap between rows. Insert the group before the placeholder group - // at the end of the previous row. - else { - $group.insertBefore($container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-groups').children().eq(-1)); - } - } - // Move right between sibling groups. - else if (_.indexOf([39, 63235], event.keyCode) > -1) { - // Move to the right if the next group is not a placeholder. - if (!$siblings.eq(index + 1).hasClass('placeholder')) { - $group.insertAfter($container.children().eq(index + 1)); - } - // Wrap group between rows. - else { - $container.closest('.ckeditor-row').next().find('.ckeditor-toolbar-groups').prepend($group); - } + } else if (_.indexOf(upDownKeys, event.keyCode) > -1) { + dir = _.indexOf([38, 63232], event.keyCode) > -1 ? 'prev' : 'next'; + $group.closest('.ckeditor-row')[dir]().find('.ckeditor-toolbar-groups').eq(0).prepend($group); } - } - // Move groups between rows. - else if (_.indexOf(upDownKeys, event.keyCode) > -1) { - dir = (_.indexOf([38, 63232], event.keyCode) > -1) ? 'prev' : 'next'; - $group.closest('.ckeditor-row')[dir]().find('.ckeditor-toolbar-groups').eq(0).prepend($group); - } - Drupal.ckeditor.registerGroupMove(this, $group); $group.trigger('focus'); event.preventDefault(); @@ -262,5 +156,4 @@ } } }); - -})(jQuery, Drupal, Backbone, _); +})(jQuery, Drupal, Backbone, _); \ No newline at end of file diff --git a/core/modules/ckeditor/js/views/VisualView.es6.js b/core/modules/ckeditor/js/views/VisualView.es6.js new file mode 100644 index 000000000000..2d8042ebbf68 --- /dev/null +++ b/core/modules/ckeditor/js/views/VisualView.es6.js @@ -0,0 +1,273 @@ +/** + * @file + * A Backbone View that provides the visual UX view of CKEditor toolbar + * configuration. + */ + +(function (Drupal, Backbone, $) { + + 'use strict'; + + Drupal.ckeditor.VisualView = Backbone.View.extend(/** @lends Drupal.ckeditor.VisualView# */{ + + events: { + 'click .ckeditor-toolbar-group-name': 'onGroupNameClick', + 'click .ckeditor-groupnames-toggle': 'onGroupNamesToggleClick', + 'click .ckeditor-add-new-group button': 'onAddGroupButtonClick' + }, + + /** + * Backbone View for CKEditor toolbar configuration; visual UX. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.listenTo(this.model, 'change:isDirty change:groupNamesVisible', this.render); + + // Add a toggle for the button group names. + $(Drupal.theme('ckeditorButtonGroupNamesToggle')) + .prependTo(this.$el.find('#ckeditor-active-toolbar').parent()); + + this.render(); + }, + + /** + * Render function for rendering the toolbar configuration. + * + * @param {*} model + * Model used for the view. + * @param {string} [value] + * The value that was changed. + * @param {object} changedAttributes + * The attributes that was changed. + * + * @return {Drupal.ckeditor.VisualView} + * The {@link Drupal.ckeditor.VisualView} object. + */ + render: function (model, value, changedAttributes) { + this.insertPlaceholders(); + this.applySorting(); + + // Toggle button group names. + var groupNamesVisible = this.model.get('groupNamesVisible'); + // If a button was just placed in the active toolbar, ensure that the + // button group names are visible. + if (changedAttributes && changedAttributes.changes && changedAttributes.changes.isDirty) { + this.model.set({groupNamesVisible: true}, {silent: true}); + groupNamesVisible = true; + } + this.$el.find('[data-toolbar="active"]').toggleClass('ckeditor-group-names-are-visible', groupNamesVisible); + this.$el.find('.ckeditor-groupnames-toggle') + .text((groupNamesVisible) ? Drupal.t('Hide group names') : Drupal.t('Show group names')) + .attr('aria-pressed', groupNamesVisible); + + return this; + }, + + /** + * Handles clicks to a button group name. + * + * @param {jQuery.Event} event + * The click event on the button group. + */ + onGroupNameClick: function (event) { + var $group = $(event.currentTarget).closest('.ckeditor-toolbar-group'); + Drupal.ckeditor.openGroupNameDialog(this, $group); + + event.stopPropagation(); + event.preventDefault(); + }, + + /** + * Handles clicks on the button group names toggle button. + * + * @param {jQuery.Event} event + * The click event on the toggle button. + */ + onGroupNamesToggleClick: function (event) { + this.model.set('groupNamesVisible', !this.model.get('groupNamesVisible')); + event.preventDefault(); + }, + + /** + * Prompts the user to provide a name for a new button group; inserts it. + * + * @param {jQuery.Event} event + * The event of the button click. + */ + onAddGroupButtonClick: function (event) { + + /** + * Inserts a new button if the openGroupNameDialog function returns true. + * + * @param {bool} success + * A flag that indicates if the user created a new group (true) or + * canceled out of the dialog (false). + * @param {jQuery} $group + * A jQuery DOM fragment that represents the new button group. It has + * not been added to the DOM yet. + */ + function insertNewGroup(success, $group) { + if (success) { + $group.appendTo($(event.currentTarget).closest('.ckeditor-row').children('.ckeditor-toolbar-groups')); + // Focus on the new group. + $group.trigger('focus'); + } + } + + // Pass in a DOM fragment of a placeholder group so that the new group + // name can be applied to it. + Drupal.ckeditor.openGroupNameDialog(this, $(Drupal.theme('ckeditorToolbarGroup')), insertNewGroup); + + event.preventDefault(); + }, + + /** + * Handles jQuery Sortable stop sort of a button group. + * + * @param {jQuery.Event} event + * The event triggered on the group drag. + * @param {object} ui + * A jQuery.ui.sortable argument that contains information about the + * elements involved in the sort action. + */ + endGroupDrag: function (event, ui) { + var view = this; + Drupal.ckeditor.registerGroupMove(this, ui.item, function (success) { + if (!success) { + // Cancel any sorting in the configuration area. + view.$el.find('.ckeditor-toolbar-configuration').find('.ui-sortable').sortable('cancel'); + } + }); + }, + + /** + * Handles jQuery Sortable start sort of a button. + * + * @param {jQuery.Event} event + * The event triggered on the group drag. + * @param {object} ui + * A jQuery.ui.sortable argument that contains information about the + * elements involved in the sort action. + */ + startButtonDrag: function (event, ui) { + this.$el.find('a:focus').trigger('blur'); + + // Show the button group names as soon as the user starts dragging. + this.model.set('groupNamesVisible', true); + }, + + /** + * Handles jQuery Sortable stop sort of a button. + * + * @param {jQuery.Event} event + * The event triggered on the button drag. + * @param {object} ui + * A jQuery.ui.sortable argument that contains information about the + * elements involved in the sort action. + */ + endButtonDrag: function (event, ui) { + var view = this; + Drupal.ckeditor.registerButtonMove(this, ui.item, function (success) { + if (!success) { + // Cancel any sorting in the configuration area. + view.$el.find('.ui-sortable').sortable('cancel'); + } + // Refocus the target button so that the user can continue from a known + // place. + ui.item.find('a').trigger('focus'); + }); + }, + + /** + * Invokes jQuery.sortable() on new buttons and groups in a CKEditor config. + */ + applySorting: function () { + // Make the buttons sortable. + this.$el.find('.ckeditor-buttons').not('.ui-sortable').sortable({ + // Change this to .ckeditor-toolbar-group-buttons. + connectWith: '.ckeditor-buttons', + placeholder: 'ckeditor-button-placeholder', + forcePlaceholderSize: true, + tolerance: 'pointer', + cursor: 'move', + start: this.startButtonDrag.bind(this), + // Sorting within a sortable. + stop: this.endButtonDrag.bind(this) + }).disableSelection(); + + // Add the drag and drop functionality to button groups. + this.$el.find('.ckeditor-toolbar-groups').not('.ui-sortable').sortable({ + connectWith: '.ckeditor-toolbar-groups', + cancel: '.ckeditor-add-new-group', + placeholder: 'ckeditor-toolbar-group-placeholder', + forcePlaceholderSize: true, + cursor: 'move', + stop: this.endGroupDrag.bind(this) + }); + + // Add the drag and drop functionality to buttons. + this.$el.find('.ckeditor-multiple-buttons li').draggable({ + connectToSortable: '.ckeditor-toolbar-active .ckeditor-buttons', + helper: 'clone' + }); + }, + + /** + * Wraps the invocation of methods to insert blank groups and rows. + */ + insertPlaceholders: function () { + this.insertPlaceholderRow(); + this.insertNewGroupButtons(); + }, + + /** + * Inserts a blank row at the bottom of the CKEditor configuration. + */ + insertPlaceholderRow: function () { + var $rows = this.$el.find('.ckeditor-row'); + // Add a placeholder row. to the end of the list if one does not exist. + if (!$rows.eq(-1).hasClass('placeholder')) { + this.$el + .find('.ckeditor-toolbar-active') + .children('.ckeditor-active-toolbar-configuration') + .append(Drupal.theme('ckeditorRow')); + } + // Update the $rows variable to include the new row. + $rows = this.$el.find('.ckeditor-row'); + // Remove blank rows except the last one. + var len = $rows.length; + $rows.filter(function (index, row) { + // Do not remove the last row. + if (index + 1 === len) { + return false; + } + return $(row).find('.ckeditor-toolbar-group').not('.placeholder').length === 0; + }) + // Then get all rows that are placeholders and remove them. + .remove(); + }, + + /** + * Inserts a button in each row that will add a new CKEditor button group. + */ + insertNewGroupButtons: function () { + // Insert an add group button to each row. + this.$el.find('.ckeditor-row').each(function () { + var $row = $(this); + var $groups = $row.find('.ckeditor-toolbar-group'); + var $button = $row.find('.ckeditor-add-new-group'); + if ($button.length === 0) { + $row.children('.ckeditor-toolbar-groups').append(Drupal.theme('ckeditorNewButtonGroup')); + } + // If a placeholder group exists, make sure it's at the end of the row. + else if (!$groups.eq(-1).hasClass('ckeditor-add-new-group')) { + $button.appendTo($row.children('.ckeditor-toolbar-groups')); + } + }); + } + }); + +})(Drupal, Backbone, jQuery); diff --git a/core/modules/ckeditor/js/views/VisualView.js b/core/modules/ckeditor/js/views/VisualView.js index 2d8042ebbf68..67368d8901a2 100644 --- a/core/modules/ckeditor/js/views/VisualView.js +++ b/core/modules/ckeditor/js/views/VisualView.js @@ -1,14 +1,16 @@ /** - * @file - * A Backbone View that provides the visual UX view of CKEditor toolbar - * configuration. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/js/views/VisualView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone, $) { 'use strict'; - Drupal.ckeditor.VisualView = Backbone.View.extend(/** @lends Drupal.ckeditor.VisualView# */{ + Drupal.ckeditor.VisualView = Backbone.View.extend({ events: { 'click .ckeditor-toolbar-group-name': 'onGroupNameClick', @@ -16,63 +18,31 @@ 'click .ckeditor-add-new-group button': 'onAddGroupButtonClick' }, - /** - * Backbone View for CKEditor toolbar configuration; visual UX. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + initialize: function initialize() { this.listenTo(this.model, 'change:isDirty change:groupNamesVisible', this.render); - // Add a toggle for the button group names. - $(Drupal.theme('ckeditorButtonGroupNamesToggle')) - .prependTo(this.$el.find('#ckeditor-active-toolbar').parent()); + $(Drupal.theme('ckeditorButtonGroupNamesToggle')).prependTo(this.$el.find('#ckeditor-active-toolbar').parent()); this.render(); }, - /** - * Render function for rendering the toolbar configuration. - * - * @param {*} model - * Model used for the view. - * @param {string} [value] - * The value that was changed. - * @param {object} changedAttributes - * The attributes that was changed. - * - * @return {Drupal.ckeditor.VisualView} - * The {@link Drupal.ckeditor.VisualView} object. - */ - render: function (model, value, changedAttributes) { + render: function render(model, value, changedAttributes) { this.insertPlaceholders(); this.applySorting(); - // Toggle button group names. var groupNamesVisible = this.model.get('groupNamesVisible'); - // If a button was just placed in the active toolbar, ensure that the - // button group names are visible. + if (changedAttributes && changedAttributes.changes && changedAttributes.changes.isDirty) { - this.model.set({groupNamesVisible: true}, {silent: true}); + this.model.set({ groupNamesVisible: true }, { silent: true }); groupNamesVisible = true; } this.$el.find('[data-toolbar="active"]').toggleClass('ckeditor-group-names-are-visible', groupNamesVisible); - this.$el.find('.ckeditor-groupnames-toggle') - .text((groupNamesVisible) ? Drupal.t('Hide group names') : Drupal.t('Show group names')) - .attr('aria-pressed', groupNamesVisible); + this.$el.find('.ckeditor-groupnames-toggle').text(groupNamesVisible ? Drupal.t('Hide group names') : Drupal.t('Show group names')).attr('aria-pressed', groupNamesVisible); return this; }, - /** - * Handles clicks to a button group name. - * - * @param {jQuery.Event} event - * The click event on the button group. - */ - onGroupNameClick: function (event) { + onGroupNameClick: function onGroupNameClick(event) { var $group = $(event.currentTarget).closest('.ckeditor-toolbar-group'); Drupal.ckeditor.openGroupNameDialog(this, $group); @@ -80,125 +50,63 @@ event.preventDefault(); }, - /** - * Handles clicks on the button group names toggle button. - * - * @param {jQuery.Event} event - * The click event on the toggle button. - */ - onGroupNamesToggleClick: function (event) { + onGroupNamesToggleClick: function onGroupNamesToggleClick(event) { this.model.set('groupNamesVisible', !this.model.get('groupNamesVisible')); event.preventDefault(); }, - /** - * Prompts the user to provide a name for a new button group; inserts it. - * - * @param {jQuery.Event} event - * The event of the button click. - */ - onAddGroupButtonClick: function (event) { - - /** - * Inserts a new button if the openGroupNameDialog function returns true. - * - * @param {bool} success - * A flag that indicates if the user created a new group (true) or - * canceled out of the dialog (false). - * @param {jQuery} $group - * A jQuery DOM fragment that represents the new button group. It has - * not been added to the DOM yet. - */ + onAddGroupButtonClick: function onAddGroupButtonClick(event) { function insertNewGroup(success, $group) { if (success) { $group.appendTo($(event.currentTarget).closest('.ckeditor-row').children('.ckeditor-toolbar-groups')); - // Focus on the new group. + $group.trigger('focus'); } } - // Pass in a DOM fragment of a placeholder group so that the new group - // name can be applied to it. Drupal.ckeditor.openGroupNameDialog(this, $(Drupal.theme('ckeditorToolbarGroup')), insertNewGroup); event.preventDefault(); }, - /** - * Handles jQuery Sortable stop sort of a button group. - * - * @param {jQuery.Event} event - * The event triggered on the group drag. - * @param {object} ui - * A jQuery.ui.sortable argument that contains information about the - * elements involved in the sort action. - */ - endGroupDrag: function (event, ui) { + endGroupDrag: function endGroupDrag(event, ui) { var view = this; Drupal.ckeditor.registerGroupMove(this, ui.item, function (success) { if (!success) { - // Cancel any sorting in the configuration area. view.$el.find('.ckeditor-toolbar-configuration').find('.ui-sortable').sortable('cancel'); } }); }, - /** - * Handles jQuery Sortable start sort of a button. - * - * @param {jQuery.Event} event - * The event triggered on the group drag. - * @param {object} ui - * A jQuery.ui.sortable argument that contains information about the - * elements involved in the sort action. - */ - startButtonDrag: function (event, ui) { + startButtonDrag: function startButtonDrag(event, ui) { this.$el.find('a:focus').trigger('blur'); - // Show the button group names as soon as the user starts dragging. this.model.set('groupNamesVisible', true); }, - /** - * Handles jQuery Sortable stop sort of a button. - * - * @param {jQuery.Event} event - * The event triggered on the button drag. - * @param {object} ui - * A jQuery.ui.sortable argument that contains information about the - * elements involved in the sort action. - */ - endButtonDrag: function (event, ui) { + endButtonDrag: function endButtonDrag(event, ui) { var view = this; Drupal.ckeditor.registerButtonMove(this, ui.item, function (success) { if (!success) { - // Cancel any sorting in the configuration area. view.$el.find('.ui-sortable').sortable('cancel'); } - // Refocus the target button so that the user can continue from a known - // place. + ui.item.find('a').trigger('focus'); }); }, - /** - * Invokes jQuery.sortable() on new buttons and groups in a CKEditor config. - */ - applySorting: function () { - // Make the buttons sortable. + applySorting: function applySorting() { this.$el.find('.ckeditor-buttons').not('.ui-sortable').sortable({ - // Change this to .ckeditor-toolbar-group-buttons. connectWith: '.ckeditor-buttons', placeholder: 'ckeditor-button-placeholder', forcePlaceholderSize: true, tolerance: 'pointer', cursor: 'move', start: this.startButtonDrag.bind(this), - // Sorting within a sortable. + stop: this.endButtonDrag.bind(this) }).disableSelection(); - // Add the drag and drop functionality to button groups. this.$el.find('.ckeditor-toolbar-groups').not('.ui-sortable').sortable({ connectWith: '.ckeditor-toolbar-groups', cancel: '.ckeditor-add-new-group', @@ -208,66 +116,46 @@ stop: this.endGroupDrag.bind(this) }); - // Add the drag and drop functionality to buttons. this.$el.find('.ckeditor-multiple-buttons li').draggable({ connectToSortable: '.ckeditor-toolbar-active .ckeditor-buttons', helper: 'clone' }); }, - /** - * Wraps the invocation of methods to insert blank groups and rows. - */ - insertPlaceholders: function () { + insertPlaceholders: function insertPlaceholders() { this.insertPlaceholderRow(); this.insertNewGroupButtons(); }, - /** - * Inserts a blank row at the bottom of the CKEditor configuration. - */ - insertPlaceholderRow: function () { + insertPlaceholderRow: function insertPlaceholderRow() { var $rows = this.$el.find('.ckeditor-row'); - // Add a placeholder row. to the end of the list if one does not exist. + if (!$rows.eq(-1).hasClass('placeholder')) { - this.$el - .find('.ckeditor-toolbar-active') - .children('.ckeditor-active-toolbar-configuration') - .append(Drupal.theme('ckeditorRow')); + this.$el.find('.ckeditor-toolbar-active').children('.ckeditor-active-toolbar-configuration').append(Drupal.theme('ckeditorRow')); } - // Update the $rows variable to include the new row. + $rows = this.$el.find('.ckeditor-row'); - // Remove blank rows except the last one. + var len = $rows.length; $rows.filter(function (index, row) { - // Do not remove the last row. if (index + 1 === len) { return false; } return $(row).find('.ckeditor-toolbar-group').not('.placeholder').length === 0; - }) - // Then get all rows that are placeholders and remove them. - .remove(); + }).remove(); }, - /** - * Inserts a button in each row that will add a new CKEditor button group. - */ - insertNewGroupButtons: function () { - // Insert an add group button to each row. + insertNewGroupButtons: function insertNewGroupButtons() { this.$el.find('.ckeditor-row').each(function () { var $row = $(this); var $groups = $row.find('.ckeditor-toolbar-group'); var $button = $row.find('.ckeditor-add-new-group'); if ($button.length === 0) { $row.children('.ckeditor-toolbar-groups').append(Drupal.theme('ckeditorNewButtonGroup')); - } - // If a placeholder group exists, make sure it's at the end of the row. - else if (!$groups.eq(-1).hasClass('ckeditor-add-new-group')) { - $button.appendTo($row.children('.ckeditor-toolbar-groups')); - } + } else if (!$groups.eq(-1).hasClass('ckeditor-add-new-group')) { + $button.appendTo($row.children('.ckeditor-toolbar-groups')); + } }); } }); - -})(Drupal, Backbone, jQuery); +})(Drupal, Backbone, jQuery); \ No newline at end of file diff --git a/core/modules/ckeditor/tests/modules/js/ajax-css.es6.js b/core/modules/ckeditor/tests/modules/js/ajax-css.es6.js new file mode 100644 index 000000000000..142b4eea0f4d --- /dev/null +++ b/core/modules/ckeditor/tests/modules/js/ajax-css.es6.js @@ -0,0 +1,24 @@ +/** + * @file + * Contains client-side code for testing CSS delivered to CKEditor via AJAX. + */ + +(function (Drupal, ckeditor, editorSettings, $) { + + 'use strict'; + + Drupal.behaviors.ajaxCssForm = { + + attach: function (context) { + // Initialize an inline CKEditor on the #edit-inline element if it + // isn't editable already. + $(context) + .find('#edit-inline') + .not('[contenteditable]') + .each(function () { + ckeditor.attachInlineEditor(this, editorSettings.formats.test_format); + }); + } + }; + +})(Drupal, Drupal.editors.ckeditor, drupalSettings.editor, jQuery); diff --git a/core/modules/ckeditor/tests/modules/js/ajax-css.js b/core/modules/ckeditor/tests/modules/js/ajax-css.js index 142b4eea0f4d..ca78134f5114 100644 --- a/core/modules/ckeditor/tests/modules/js/ajax-css.js +++ b/core/modules/ckeditor/tests/modules/js/ajax-css.js @@ -1,7 +1,10 @@ /** - * @file - * Contains client-side code for testing CSS delivered to CKEditor via AJAX. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/ckeditor/tests/modules/js/ajax-css.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, ckeditor, editorSettings, $) { @@ -9,16 +12,10 @@ Drupal.behaviors.ajaxCssForm = { - attach: function (context) { - // Initialize an inline CKEditor on the #edit-inline element if it - // isn't editable already. - $(context) - .find('#edit-inline') - .not('[contenteditable]') - .each(function () { - ckeditor.attachInlineEditor(this, editorSettings.formats.test_format); - }); + attach: function attach(context) { + $(context).find('#edit-inline').not('[contenteditable]').each(function () { + ckeditor.attachInlineEditor(this, editorSettings.formats.test_format); + }); } }; - -})(Drupal, Drupal.editors.ckeditor, drupalSettings.editor, jQuery); +})(Drupal, Drupal.editors.ckeditor, drupalSettings.editor, jQuery); \ No newline at end of file diff --git a/core/modules/color/color.es6.js b/core/modules/color/color.es6.js new file mode 100644 index 000000000000..984f740126d4 --- /dev/null +++ b/core/modules/color/color.es6.js @@ -0,0 +1,297 @@ +/** + * @file + * Attaches the behaviors for the Color module. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Displays farbtastic color selector and initialize color administration UI. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attach color selection behavior to relevant context. + */ + Drupal.behaviors.color = { + attach: function (context, settings) { + var i; + var j; + var colors; + // This behavior attaches by ID, so is only valid once on a page. + var form = $(context).find('#system-theme-settings .color-form').once('color'); + if (form.length === 0) { + return; + } + var inputs = []; + var hooks = []; + var locks = []; + var focused = null; + + // Add Farbtastic. + $('<div class="color-placeholder"></div>').once('color').prependTo(form); + var farb = $.farbtastic('.color-placeholder'); + + // Decode reference colors to HSL. + var reference = settings.color.reference; + for (i in reference) { + if (reference.hasOwnProperty(i)) { + reference[i] = farb.RGBToHSL(farb.unpack(reference[i])); + } + } + + // Build a preview. + var height = []; + var width = []; + // Loop through all defined gradients. + for (i in settings.gradients) { + if (settings.gradients.hasOwnProperty(i)) { + // Add element to display the gradient. + $('.color-preview').once('color').append('<div id="gradient-' + i + '"></div>'); + var gradient = $('.color-preview #gradient-' + i); + // Add height of current gradient to the list (divided by 10). + height.push(parseInt(gradient.css('height'), 10) / 10); + // Add width of current gradient to the list (divided by 10). + width.push(parseInt(gradient.css('width'), 10) / 10); + // Add rows (or columns for horizontal gradients). + // Each gradient line should have a height (or width for horizontal + // gradients) of 10px (because we divided the height/width by 10 + // above). + for (j = 0; j < (settings.gradients[i].direction === 'vertical' ? height[i] : width[i]); ++j) { + gradient.append('<div class="gradient-line"></div>'); + } + } + } + + // Set up colorScheme selector. + form.find('#edit-scheme').on('change', function () { + var schemes = settings.color.schemes; + var colorScheme = this.options[this.selectedIndex].value; + if (colorScheme !== '' && schemes[colorScheme]) { + // Get colors of active scheme. + colors = schemes[colorScheme]; + for (var fieldName in colors) { + if (colors.hasOwnProperty(fieldName)) { + callback($('#edit-palette-' + fieldName), colors[fieldName], false, true); + } + } + preview(); + } + }); + + /** + * Renders the preview. + */ + function preview() { + Drupal.color.callback(context, settings, form, farb, height, width); + } + + /** + * Shifts a given color, using a reference pair (ref in HSL). + * + * This algorithm ensures relative ordering on the saturation and + * luminance axes is preserved, and performs a simple hue shift. + * + * It is also symmetrical. If: shift_color(c, a, b) === d, then + * shift_color(d, b, a) === c. + * + * @function Drupal.color~shift_color + * + * @param {string} given + * A hex color code to shift. + * @param {Array.<number>} ref1 + * First HSL color reference. + * @param {Array.<number>} ref2 + * Second HSL color reference. + * + * @return {string} + * A hex color, shifted. + */ + function shift_color(given, ref1, ref2) { + var d; + // Convert to HSL. + given = farb.RGBToHSL(farb.unpack(given)); + + // Hue: apply delta. + given[0] += ref2[0] - ref1[0]; + + // Saturation: interpolate. + if (ref1[1] === 0 || ref2[1] === 0) { + given[1] = ref2[1]; + } + else { + d = ref1[1] / ref2[1]; + if (d > 1) { + given[1] /= d; + } + else { + given[1] = 1 - (1 - given[1]) * d; + } + } + + // Luminance: interpolate. + if (ref1[2] === 0 || ref2[2] === 0) { + given[2] = ref2[2]; + } + else { + d = ref1[2] / ref2[2]; + if (d > 1) { + given[2] /= d; + } + else { + given[2] = 1 - (1 - given[2]) * d; + } + } + + return farb.pack(farb.HSLToRGB(given)); + } + + /** + * Callback for Farbtastic when a new color is chosen. + * + * @param {HTMLElement} input + * The input element where the color is chosen. + * @param {string} color + * The color that was chosen through the input. + * @param {bool} propagate + * Whether or not to propagate the color to a locked pair value + * @param {bool} colorScheme + * Flag to indicate if the user is using a color scheme when changing + * the color. + */ + function callback(input, color, propagate, colorScheme) { + var matched; + // Set background/foreground colors. + $(input).css({ + backgroundColor: color, + color: farb.RGBToHSL(farb.unpack(color))[2] > 0.5 ? '#000' : '#fff' + }); + + // Change input value. + if ($(input).val() && $(input).val() !== color) { + $(input).val(color); + + // Update locked values. + if (propagate) { + i = input.i; + for (j = i + 1; ; ++j) { + if (!locks[j - 1] || $(locks[j - 1]).is('.is-unlocked')) { + break; + } + matched = shift_color(color, reference[input.key], reference[inputs[j].key]); + callback(inputs[j], matched, false); + } + for (j = i - 1; ; --j) { + if (!locks[j] || $(locks[j]).is('.is-unlocked')) { + break; + } + matched = shift_color(color, reference[input.key], reference[inputs[j].key]); + callback(inputs[j], matched, false); + } + + // Update preview. + preview(); + } + + // Reset colorScheme selector. + if (!colorScheme) { + resetScheme(); + } + } + } + + /** + * Resets the color scheme selector. + */ + function resetScheme() { + form.find('#edit-scheme').each(function () { + this.selectedIndex = this.options.length - 1; + }); + } + + /** + * Focuses Farbtastic on a particular field. + * + * @param {jQuery.Event} e + * The focus event on the field. + */ + function focus(e) { + var input = e.target; + // Remove old bindings. + if (focused) { + $(focused).off('keyup', farb.updateValue) + .off('keyup', preview).off('keyup', resetScheme) + .parent().removeClass('item-selected'); + } + + // Add new bindings. + focused = input; + farb.linkTo(function (color) { callback(input, color, true, false); }); + farb.setColor(input.value); + $(focused).on('keyup', farb.updateValue).on('keyup', preview).on('keyup', resetScheme) + .parent().addClass('item-selected'); + } + + // Initialize color fields. + form.find('.js-color-palette input.form-text') + .each(function () { + // Extract palette field name. + this.key = this.id.substring(13); + + // Link to color picker temporarily to initialize. + farb.linkTo(function () {}).setColor('#000').linkTo(this); + + // Add lock. + var i = inputs.length; + if (inputs.length) { + var toggleClick = true; + var lock = $('<button class="color-palette__lock">' + Drupal.t('Unlock') + '</button>').on('click', function (e) { + e.preventDefault(); + if (toggleClick) { + $(this).addClass('is-unlocked').html(Drupal.t('Lock')); + $(hooks[i - 1]).attr('class', + locks[i - 2] && $(locks[i - 2]).is(':not(.is-unlocked)') ? 'color-palette__hook is-up' : 'color-palette__hook' + ); + $(hooks[i]).attr('class', + locks[i] && $(locks[i]).is(':not(.is-unlocked)') ? 'color-palette__hook is-down' : 'color-palette__hook' + ); + } + else { + $(this).removeClass('is-unlocked').html(Drupal.t('Unlock')); + $(hooks[i - 1]).attr('class', + locks[i - 2] && $(locks[i - 2]).is(':not(.is-unlocked)') ? 'color-palette__hook is-both' : 'color-palette__hook is-down' + ); + $(hooks[i]).attr('class', + locks[i] && $(locks[i]).is(':not(.is-unlocked)') ? 'color-palette__hook is-both' : 'color-palette__hook is-up' + ); + } + toggleClick = !toggleClick; + }); + $(this).after(lock); + locks.push(lock); + } + + // Add hook. + var hook = $('<div class="color-palette__hook"></div>'); + $(this).after(hook); + hooks.push(hook); + + $(this).parent().find('.color-palette__lock').trigger('click'); + this.i = i; + inputs.push(this); + }) + .on('focus', focus); + + form.find('.js-color-palette label'); + + // Focus first color. + inputs[0].focus(); + + // Render preview. + preview(); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/color/color.js b/core/modules/color/color.js index 984f740126d4..78d3ba5f6937 100644 --- a/core/modules/color/color.js +++ b/core/modules/color/color.js @@ -1,26 +1,21 @@ /** - * @file - * Attaches the behaviors for the Color module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/color/color.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Displays farbtastic color selector and initialize color administration UI. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attach color selection behavior to relevant context. - */ Drupal.behaviors.color = { - attach: function (context, settings) { + attach: function attach(context, settings) { var i; var j; var colors; - // This behavior attaches by ID, so is only valid once on a page. + var form = $(context).find('#system-theme-settings .color-form').once('color'); if (form.length === 0) { return; @@ -30,11 +25,9 @@ var locks = []; var focused = null; - // Add Farbtastic. $('<div class="color-placeholder"></div>').once('color').prependTo(form); var farb = $.farbtastic('.color-placeholder'); - // Decode reference colors to HSL. var reference = settings.color.reference; for (i in reference) { if (reference.hasOwnProperty(i)) { @@ -42,35 +35,28 @@ } } - // Build a preview. var height = []; var width = []; - // Loop through all defined gradients. + for (i in settings.gradients) { if (settings.gradients.hasOwnProperty(i)) { - // Add element to display the gradient. $('.color-preview').once('color').append('<div id="gradient-' + i + '"></div>'); var gradient = $('.color-preview #gradient-' + i); - // Add height of current gradient to the list (divided by 10). + height.push(parseInt(gradient.css('height'), 10) / 10); - // Add width of current gradient to the list (divided by 10). + width.push(parseInt(gradient.css('width'), 10) / 10); - // Add rows (or columns for horizontal gradients). - // Each gradient line should have a height (or width for horizontal - // gradients) of 10px (because we divided the height/width by 10 - // above). + for (j = 0; j < (settings.gradients[i].direction === 'vertical' ? height[i] : width[i]); ++j) { gradient.append('<div class="gradient-line"></div>'); } } } - // Set up colorScheme selector. form.find('#edit-scheme').on('change', function () { var schemes = settings.color.schemes; var colorScheme = this.options[this.selectedIndex].value; if (colorScheme !== '' && schemes[colorScheme]) { - // Get colors of active scheme. colors = schemes[colorScheme]; for (var fieldName in colors) { if (colors.hasOwnProperty(fieldName)) { @@ -81,66 +67,35 @@ } }); - /** - * Renders the preview. - */ function preview() { Drupal.color.callback(context, settings, form, farb, height, width); } - /** - * Shifts a given color, using a reference pair (ref in HSL). - * - * This algorithm ensures relative ordering on the saturation and - * luminance axes is preserved, and performs a simple hue shift. - * - * It is also symmetrical. If: shift_color(c, a, b) === d, then - * shift_color(d, b, a) === c. - * - * @function Drupal.color~shift_color - * - * @param {string} given - * A hex color code to shift. - * @param {Array.<number>} ref1 - * First HSL color reference. - * @param {Array.<number>} ref2 - * Second HSL color reference. - * - * @return {string} - * A hex color, shifted. - */ function shift_color(given, ref1, ref2) { var d; - // Convert to HSL. + given = farb.RGBToHSL(farb.unpack(given)); - // Hue: apply delta. given[0] += ref2[0] - ref1[0]; - // Saturation: interpolate. if (ref1[1] === 0 || ref2[1] === 0) { given[1] = ref2[1]; - } - else { + } else { d = ref1[1] / ref2[1]; if (d > 1) { given[1] /= d; - } - else { + } else { given[1] = 1 - (1 - given[1]) * d; } } - // Luminance: interpolate. if (ref1[2] === 0 || ref2[2] === 0) { given[2] = ref2[2]; - } - else { + } else { d = ref1[2] / ref2[2]; if (d > 1) { given[2] /= d; - } - else { + } else { given[2] = 1 - (1 - given[2]) * d; } } @@ -148,42 +103,27 @@ return farb.pack(farb.HSLToRGB(given)); } - /** - * Callback for Farbtastic when a new color is chosen. - * - * @param {HTMLElement} input - * The input element where the color is chosen. - * @param {string} color - * The color that was chosen through the input. - * @param {bool} propagate - * Whether or not to propagate the color to a locked pair value - * @param {bool} colorScheme - * Flag to indicate if the user is using a color scheme when changing - * the color. - */ function callback(input, color, propagate, colorScheme) { var matched; - // Set background/foreground colors. + $(input).css({ backgroundColor: color, color: farb.RGBToHSL(farb.unpack(color))[2] > 0.5 ? '#000' : '#fff' }); - // Change input value. if ($(input).val() && $(input).val() !== color) { $(input).val(color); - // Update locked values. if (propagate) { i = input.i; - for (j = i + 1; ; ++j) { + for (j = i + 1;; ++j) { if (!locks[j - 1] || $(locks[j - 1]).is('.is-unlocked')) { break; } matched = shift_color(color, reference[input.key], reference[inputs[j].key]); callback(inputs[j], matched, false); } - for (j = i - 1; ; --j) { + for (j = i - 1;; --j) { if (!locks[j] || $(locks[j]).is('.is-unlocked')) { break; } @@ -191,107 +131,75 @@ callback(inputs[j], matched, false); } - // Update preview. preview(); } - // Reset colorScheme selector. if (!colorScheme) { resetScheme(); } } } - /** - * Resets the color scheme selector. - */ function resetScheme() { form.find('#edit-scheme').each(function () { this.selectedIndex = this.options.length - 1; }); } - /** - * Focuses Farbtastic on a particular field. - * - * @param {jQuery.Event} e - * The focus event on the field. - */ function focus(e) { var input = e.target; - // Remove old bindings. + if (focused) { - $(focused).off('keyup', farb.updateValue) - .off('keyup', preview).off('keyup', resetScheme) - .parent().removeClass('item-selected'); + $(focused).off('keyup', farb.updateValue).off('keyup', preview).off('keyup', resetScheme).parent().removeClass('item-selected'); } - // Add new bindings. focused = input; - farb.linkTo(function (color) { callback(input, color, true, false); }); + farb.linkTo(function (color) { + callback(input, color, true, false); + }); farb.setColor(input.value); - $(focused).on('keyup', farb.updateValue).on('keyup', preview).on('keyup', resetScheme) - .parent().addClass('item-selected'); + $(focused).on('keyup', farb.updateValue).on('keyup', preview).on('keyup', resetScheme).parent().addClass('item-selected'); } - // Initialize color fields. - form.find('.js-color-palette input.form-text') - .each(function () { - // Extract palette field name. - this.key = this.id.substring(13); - - // Link to color picker temporarily to initialize. - farb.linkTo(function () {}).setColor('#000').linkTo(this); - - // Add lock. - var i = inputs.length; - if (inputs.length) { - var toggleClick = true; - var lock = $('<button class="color-palette__lock">' + Drupal.t('Unlock') + '</button>').on('click', function (e) { - e.preventDefault(); - if (toggleClick) { - $(this).addClass('is-unlocked').html(Drupal.t('Lock')); - $(hooks[i - 1]).attr('class', - locks[i - 2] && $(locks[i - 2]).is(':not(.is-unlocked)') ? 'color-palette__hook is-up' : 'color-palette__hook' - ); - $(hooks[i]).attr('class', - locks[i] && $(locks[i]).is(':not(.is-unlocked)') ? 'color-palette__hook is-down' : 'color-palette__hook' - ); - } - else { - $(this).removeClass('is-unlocked').html(Drupal.t('Unlock')); - $(hooks[i - 1]).attr('class', - locks[i - 2] && $(locks[i - 2]).is(':not(.is-unlocked)') ? 'color-palette__hook is-both' : 'color-palette__hook is-down' - ); - $(hooks[i]).attr('class', - locks[i] && $(locks[i]).is(':not(.is-unlocked)') ? 'color-palette__hook is-both' : 'color-palette__hook is-up' - ); - } - toggleClick = !toggleClick; - }); - $(this).after(lock); - locks.push(lock); - } + form.find('.js-color-palette input.form-text').each(function () { + this.key = this.id.substring(13); + + farb.linkTo(function () {}).setColor('#000').linkTo(this); + + var i = inputs.length; + if (inputs.length) { + var toggleClick = true; + var lock = $('<button class="color-palette__lock">' + Drupal.t('Unlock') + '</button>').on('click', function (e) { + e.preventDefault(); + if (toggleClick) { + $(this).addClass('is-unlocked').html(Drupal.t('Lock')); + $(hooks[i - 1]).attr('class', locks[i - 2] && $(locks[i - 2]).is(':not(.is-unlocked)') ? 'color-palette__hook is-up' : 'color-palette__hook'); + $(hooks[i]).attr('class', locks[i] && $(locks[i]).is(':not(.is-unlocked)') ? 'color-palette__hook is-down' : 'color-palette__hook'); + } else { + $(this).removeClass('is-unlocked').html(Drupal.t('Unlock')); + $(hooks[i - 1]).attr('class', locks[i - 2] && $(locks[i - 2]).is(':not(.is-unlocked)') ? 'color-palette__hook is-both' : 'color-palette__hook is-down'); + $(hooks[i]).attr('class', locks[i] && $(locks[i]).is(':not(.is-unlocked)') ? 'color-palette__hook is-both' : 'color-palette__hook is-up'); + } + toggleClick = !toggleClick; + }); + $(this).after(lock); + locks.push(lock); + } - // Add hook. - var hook = $('<div class="color-palette__hook"></div>'); - $(this).after(hook); - hooks.push(hook); + var hook = $('<div class="color-palette__hook"></div>'); + $(this).after(hook); + hooks.push(hook); - $(this).parent().find('.color-palette__lock').trigger('click'); - this.i = i; - inputs.push(this); - }) - .on('focus', focus); + $(this).parent().find('.color-palette__lock').trigger('click'); + this.i = i; + inputs.push(this); + }).on('focus', focus); form.find('.js-color-palette label'); - // Focus first color. inputs[0].focus(); - // Render preview. preview(); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/color/preview.es6.js b/core/modules/color/preview.es6.js new file mode 100644 index 000000000000..38c62aef2679 --- /dev/null +++ b/core/modules/color/preview.es6.js @@ -0,0 +1,74 @@ +/** + * @file + * Attaches preview-related behavior for the Color module. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Namespace for color-related functionality for Drupal. + * + * @namespace + */ + Drupal.color = { + + /** + * The callback for when the color preview has been attached. + * + * @param {Element} context + * The context to initiate the color behaviour. + * @param {object} settings + * Settings for the color functionality. + * @param {HTMLFormElement} form + * The form to initiate the color behaviour on. + * @param {object} farb + * The farbtastic object. + * @param {number} height + * Height of gradient. + * @param {number} width + * Width of gradient. + */ + callback: function (context, settings, form, farb, height, width) { + var accum; + var delta; + // Solid background. + form.find('.color-preview').css('backgroundColor', form.find('.color-palette input[name="palette[base]"]').val()); + + // Text preview. + form.find('#text').css('color', form.find('.color-palette input[name="palette[text]"]').val()); + form.find('#text a, #text h2').css('color', form.find('.color-palette input[name="palette[link]"]').val()); + + function gradientLineColor(i, element) { + for (var k in accum) { + if (accum.hasOwnProperty(k)) { + accum[k] += delta[k]; + } + } + element.style.backgroundColor = farb.pack(accum); + } + + // Set up gradients if there are some. + var color_start; + var color_end; + for (var i in settings.gradients) { + if (settings.gradients.hasOwnProperty(i)) { + color_start = farb.unpack(form.find('.color-palette input[name="palette[' + settings.gradients[i].colors[0] + ']"]').val()); + color_end = farb.unpack(form.find('.color-palette input[name="palette[' + settings.gradients[i].colors[1] + ']"]').val()); + if (color_start && color_end) { + delta = []; + for (var j in color_start) { + if (color_start.hasOwnProperty(j)) { + delta[j] = (color_end[j] - color_start[j]) / (settings.gradients[i].vertical ? height[i] : width[i]); + } + } + accum = color_start; + // Render gradient lines. + form.find('#gradient-' + i + ' > div').each(gradientLineColor); + } + } + } + } + }; +})(jQuery, Drupal); diff --git a/core/modules/color/preview.js b/core/modules/color/preview.js index 38c62aef2679..a303bab1e7b4 100644 --- a/core/modules/color/preview.js +++ b/core/modules/color/preview.js @@ -1,42 +1,22 @@ /** - * @file - * Attaches preview-related behavior for the Color module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/color/preview.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Namespace for color-related functionality for Drupal. - * - * @namespace - */ Drupal.color = { - - /** - * The callback for when the color preview has been attached. - * - * @param {Element} context - * The context to initiate the color behaviour. - * @param {object} settings - * Settings for the color functionality. - * @param {HTMLFormElement} form - * The form to initiate the color behaviour on. - * @param {object} farb - * The farbtastic object. - * @param {number} height - * Height of gradient. - * @param {number} width - * Width of gradient. - */ - callback: function (context, settings, form, farb, height, width) { + callback: function callback(context, settings, form, farb, height, width) { var accum; var delta; - // Solid background. + form.find('.color-preview').css('backgroundColor', form.find('.color-palette input[name="palette[base]"]').val()); - // Text preview. form.find('#text').css('color', form.find('.color-palette input[name="palette[text]"]').val()); form.find('#text a, #text h2').css('color', form.find('.color-palette input[name="palette[link]"]').val()); @@ -49,7 +29,6 @@ element.style.backgroundColor = farb.pack(accum); } - // Set up gradients if there are some. var color_start; var color_end; for (var i in settings.gradients) { @@ -64,11 +43,11 @@ } } accum = color_start; - // Render gradient lines. + form.find('#gradient-' + i + ' > div').each(gradientLineColor); } } } } }; -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.es6.js b/core/modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.es6.js new file mode 100644 index 000000000000..232428164a04 --- /dev/null +++ b/core/modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.es6.js @@ -0,0 +1,9 @@ +/** + * @file + * Adds javascript functions for font resizing. + */ +(function ($) { + 'use strict'; + + $(document).ready(function () {}); +})(jQuery); diff --git a/core/modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.js b/core/modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.js index 232428164a04..6881f780bb1f 100644 --- a/core/modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.js +++ b/core/modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.js @@ -1,9 +1,13 @@ /** - * @file - * Adds javascript functions for font resizing. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/color/tests/modules/color_test/themes/color_test_theme/js/color_test_theme-fontsize.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + (function ($) { 'use strict'; $(document).ready(function () {}); -})(jQuery); +})(jQuery); \ No newline at end of file diff --git a/core/modules/comment/comment-entity-form.es6.js b/core/modules/comment/comment-entity-form.es6.js new file mode 100644 index 000000000000..fd16d8bfb0e4 --- /dev/null +++ b/core/modules/comment/comment-entity-form.es6.js @@ -0,0 +1,23 @@ +/** + * @file + * Attaches comment behaviors to the entity form. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.commentFieldsetSummaries = { + attach: function (context) { + var $context = $(context); + $context.find('fieldset.comment-entity-settings-form').drupalSetSummary(function (context) { + return Drupal.checkPlain($(context).find('.js-form-item-comment input:checked').next('label').text()); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/comment/comment-entity-form.js b/core/modules/comment/comment-entity-form.js index fd16d8bfb0e4..8121e464b3df 100644 --- a/core/modules/comment/comment-entity-form.js +++ b/core/modules/comment/comment-entity-form.js @@ -1,23 +1,21 @@ /** - * @file - * Attaches comment behaviors to the entity form. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/comment/comment-entity-form.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * - * @type {Drupal~behavior} - */ Drupal.behaviors.commentFieldsetSummaries = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); $context.find('fieldset.comment-entity-settings-form').drupalSetSummary(function (context) { return Drupal.checkPlain($(context).find('.js-form-item-comment input:checked').next('label').text()); }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/comment/js/comment-by-viewer.es6.js b/core/modules/comment/js/comment-by-viewer.es6.js new file mode 100644 index 000000000000..22d4b54561e4 --- /dev/null +++ b/core/modules/comment/js/comment-by-viewer.es6.js @@ -0,0 +1,26 @@ +/** + * @file + * Attaches behaviors for the Comment module's "by-viewer" class. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Add 'by-viewer' class to comments written by the current user. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.commentByViewer = { + attach: function (context) { + var currentUserID = parseInt(drupalSettings.user.uid, 10); + $('[data-comment-user-id]') + .filter(function () { + return parseInt(this.getAttribute('data-comment-user-id'), 10) === currentUserID; + }) + .addClass('by-viewer'); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/comment/js/comment-by-viewer.js b/core/modules/comment/js/comment-by-viewer.js index 22d4b54561e4..360ab3bfcfcd 100644 --- a/core/modules/comment/js/comment-by-viewer.js +++ b/core/modules/comment/js/comment-by-viewer.js @@ -1,26 +1,21 @@ /** - * @file - * Attaches behaviors for the Comment module's "by-viewer" class. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/comment/js/comment-by-viewer.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Add 'by-viewer' class to comments written by the current user. - * - * @type {Drupal~behavior} - */ Drupal.behaviors.commentByViewer = { - attach: function (context) { + attach: function attach(context) { var currentUserID = parseInt(drupalSettings.user.uid, 10); - $('[data-comment-user-id]') - .filter(function () { - return parseInt(this.getAttribute('data-comment-user-id'), 10) === currentUserID; - }) - .addClass('by-viewer'); + $('[data-comment-user-id]').filter(function () { + return parseInt(this.getAttribute('data-comment-user-id'), 10) === currentUserID; + }).addClass('by-viewer'); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/comment/js/comment-new-indicator.es6.js b/core/modules/comment/js/comment-new-indicator.es6.js new file mode 100644 index 000000000000..d5f0a4f45c88 --- /dev/null +++ b/core/modules/comment/js/comment-new-indicator.es6.js @@ -0,0 +1,96 @@ +/** + * @file + * Attaches behaviors for the Comment module's "new" indicator. + * + * May only be loaded for authenticated users, with the History module + * installed. + */ + +(function ($, Drupal, window) { + + 'use strict'; + + /** + * Renders "new" comment indicators wherever necessary. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches "new" comment indicators behavior. + */ + Drupal.behaviors.commentNewIndicator = { + attach: function (context) { + // Collect all "new" comment indicator placeholders (and their + // corresponding node IDs) newer than 30 days ago that have not already + // been read after their last comment timestamp. + var nodeIDs = []; + var $placeholders = $(context) + .find('[data-comment-timestamp]') + .once('history') + .filter(function () { + var $placeholder = $(this); + var commentTimestamp = parseInt($placeholder.attr('data-comment-timestamp'), 10); + var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, commentTimestamp)) { + nodeIDs.push(nodeID); + return true; + } + else { + return false; + } + }); + + if ($placeholders.length === 0) { + return; + } + + // Fetch the node read timestamps from the server. + Drupal.history.fetchTimestamps(nodeIDs, function () { + processCommentNewIndicators($placeholders); + }); + } + }; + + /** + * Processes the markup for "new comment" indicators. + * + * @param {jQuery} $placeholders + * The elements that should be processed. + */ + function processCommentNewIndicators($placeholders) { + var isFirstNewComment = true; + var newCommentString = Drupal.t('new'); + var $placeholder; + + $placeholders.each(function (index, placeholder) { + $placeholder = $(placeholder); + var timestamp = parseInt($placeholder.attr('data-comment-timestamp'), 10); + var $node = $placeholder.closest('[data-history-node-id]'); + var nodeID = $node.attr('data-history-node-id'); + var lastViewTimestamp = Drupal.history.getLastRead(nodeID); + + if (timestamp > lastViewTimestamp) { + // Turn the placeholder into an actual "new" indicator. + var $comment = $(placeholder) + .removeClass('hidden') + .text(newCommentString) + .closest('.js-comment') + // Add 'new' class to the comment, so it can be styled. + .addClass('new'); + + // Insert "new" anchor just before the "comment-<cid>" anchor if + // this is the first new comment in the DOM. + if (isFirstNewComment) { + isFirstNewComment = false; + $comment.prev().before('<a id="new" />'); + // If the URL points to the first new comment, then scroll to that + // comment. + if (window.location.hash === '#new') { + window.scrollTo(0, $comment.offset().top - Drupal.displace.offsets.top); + } + } + } + }); + } + +})(jQuery, Drupal, window); diff --git a/core/modules/comment/js/comment-new-indicator.js b/core/modules/comment/js/comment-new-indicator.js index d5f0a4f45c88..b896b5df6c86 100644 --- a/core/modules/comment/js/comment-new-indicator.js +++ b/core/modules/comment/js/comment-new-indicator.js @@ -1,62 +1,40 @@ /** - * @file - * Attaches behaviors for the Comment module's "new" indicator. - * - * May only be loaded for authenticated users, with the History module - * installed. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/comment/js/comment-new-indicator.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, window) { 'use strict'; - /** - * Renders "new" comment indicators wherever necessary. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches "new" comment indicators behavior. - */ Drupal.behaviors.commentNewIndicator = { - attach: function (context) { - // Collect all "new" comment indicator placeholders (and their - // corresponding node IDs) newer than 30 days ago that have not already - // been read after their last comment timestamp. + attach: function attach(context) { var nodeIDs = []; - var $placeholders = $(context) - .find('[data-comment-timestamp]') - .once('history') - .filter(function () { - var $placeholder = $(this); - var commentTimestamp = parseInt($placeholder.attr('data-comment-timestamp'), 10); - var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id'); - if (Drupal.history.needsServerCheck(nodeID, commentTimestamp)) { - nodeIDs.push(nodeID); - return true; - } - else { - return false; - } - }); + var $placeholders = $(context).find('[data-comment-timestamp]').once('history').filter(function () { + var $placeholder = $(this); + var commentTimestamp = parseInt($placeholder.attr('data-comment-timestamp'), 10); + var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, commentTimestamp)) { + nodeIDs.push(nodeID); + return true; + } else { + return false; + } + }); if ($placeholders.length === 0) { return; } - // Fetch the node read timestamps from the server. Drupal.history.fetchTimestamps(nodeIDs, function () { processCommentNewIndicators($placeholders); }); } }; - /** - * Processes the markup for "new comment" indicators. - * - * @param {jQuery} $placeholders - * The elements that should be processed. - */ function processCommentNewIndicators($placeholders) { var isFirstNewComment = true; var newCommentString = Drupal.t('new'); @@ -70,21 +48,12 @@ var lastViewTimestamp = Drupal.history.getLastRead(nodeID); if (timestamp > lastViewTimestamp) { - // Turn the placeholder into an actual "new" indicator. - var $comment = $(placeholder) - .removeClass('hidden') - .text(newCommentString) - .closest('.js-comment') - // Add 'new' class to the comment, so it can be styled. - .addClass('new'); + var $comment = $(placeholder).removeClass('hidden').text(newCommentString).closest('.js-comment').addClass('new'); - // Insert "new" anchor just before the "comment-<cid>" anchor if - // this is the first new comment in the DOM. if (isFirstNewComment) { isFirstNewComment = false; $comment.prev().before('<a id="new" />'); - // If the URL points to the first new comment, then scroll to that - // comment. + if (window.location.hash === '#new') { window.scrollTo(0, $comment.offset().top - Drupal.displace.offsets.top); } @@ -92,5 +61,4 @@ } }); } - -})(jQuery, Drupal, window); +})(jQuery, Drupal, window); \ No newline at end of file diff --git a/core/modules/comment/js/node-new-comments-link.es6.js b/core/modules/comment/js/node-new-comments-link.es6.js new file mode 100644 index 000000000000..291bcbd11613 --- /dev/null +++ b/core/modules/comment/js/node-new-comments-link.es6.js @@ -0,0 +1,177 @@ +/** + * @file + * Attaches behaviors for the Comment module's "X new comments" link. + * + * May only be loaded for authenticated users, with the History module + * installed. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Render "X new comments" links wherever necessary. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches new comment links behavior. + */ + Drupal.behaviors.nodeNewCommentsLink = { + attach: function (context) { + // Collect all "X new comments" node link placeholders (and their + // corresponding node IDs) newer than 30 days ago that have not already + // been read after their last comment timestamp. + var nodeIDs = []; + var $placeholders = $(context) + .find('[data-history-node-last-comment-timestamp]') + .once('history') + .filter(function () { + var $placeholder = $(this); + var lastCommentTimestamp = parseInt($placeholder.attr('data-history-node-last-comment-timestamp'), 10); + var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) { + nodeIDs.push(nodeID); + // Hide this placeholder link until it is certain we'll need it. + hide($placeholder); + return true; + } + else { + // Remove this placeholder link from the DOM because we won't need + // it. + remove($placeholder); + return false; + } + }); + + if ($placeholders.length === 0) { + return; + } + + // Perform an AJAX request to retrieve node read timestamps. + Drupal.history.fetchTimestamps(nodeIDs, function () { + processNodeNewCommentLinks($placeholders); + }); + } + }; + + /** + * Hides a "new comment" element. + * + * @param {jQuery} $placeholder + * The placeholder element of the new comment link. + * + * @return {jQuery} + * The placeholder element passed in as a parameter. + */ + function hide($placeholder) { + return $placeholder + // Find the parent <li>. + .closest('.comment-new-comments') + // Find the preceding <li>, if any, and give it the 'last' class. + .prev().addClass('last') + // Go back to the parent <li> and hide it. + .end().hide(); + } + + /** + * Removes a "new comment" element. + * + * @param {jQuery} $placeholder + * The placeholder element of the new comment link. + */ + function remove($placeholder) { + hide($placeholder).remove(); + } + + /** + * Shows a "new comment" element. + * + * @param {jQuery} $placeholder + * The placeholder element of the new comment link. + * + * @return {jQuery} + * The placeholder element passed in as a parameter. + */ + function show($placeholder) { + return $placeholder + // Find the parent <li>. + .closest('.comment-new-comments') + // Find the preceding <li>, if any, and remove its 'last' class, if any. + .prev().removeClass('last') + // Go back to the parent <li> and show it. + .end().show(); + } + + /** + * Processes new comment links and adds appropriate text in relevant cases. + * + * @param {jQuery} $placeholders + * The placeholder elements of the current page. + */ + function processNodeNewCommentLinks($placeholders) { + // Figure out which placeholders need the "x new comments" links. + var $placeholdersToUpdate = {}; + var fieldName = 'comment'; + var $placeholder; + $placeholders.each(function (index, placeholder) { + $placeholder = $(placeholder); + var timestamp = parseInt($placeholder.attr('data-history-node-last-comment-timestamp'), 10); + fieldName = $placeholder.attr('data-history-node-field-name'); + var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id'); + var lastViewTimestamp = Drupal.history.getLastRead(nodeID); + + // Queue this placeholder's "X new comments" link to be downloaded from + // the server. + if (timestamp > lastViewTimestamp) { + $placeholdersToUpdate[nodeID] = $placeholder; + } + // No "X new comments" link necessary; remove it from the DOM. + else { + remove($placeholder); + } + }); + + // Perform an AJAX request to retrieve node view timestamps. + var nodeIDs = Object.keys($placeholdersToUpdate); + if (nodeIDs.length === 0) { + return; + } + + /** + * Renders the "X new comments" links. + * + * Either use the data embedded in the page or perform an AJAX request to + * retrieve the same data. + * + * @param {object} results + * Data about new comment links indexed by nodeID. + */ + function render(results) { + for (var nodeID in results) { + if (results.hasOwnProperty(nodeID) && $placeholdersToUpdate.hasOwnProperty(nodeID)) { + $placeholdersToUpdate[nodeID] + .attr('href', results[nodeID].first_new_comment_link) + .text(Drupal.formatPlural(results[nodeID].new_comment_count, '1 new comment', '@count new comments')) + .removeClass('hidden'); + show($placeholdersToUpdate[nodeID]); + } + } + } + + if (drupalSettings.comment && drupalSettings.comment.newCommentsLinks) { + render(drupalSettings.comment.newCommentsLinks.node[fieldName]); + } + else { + $.ajax({ + url: Drupal.url('comments/render_new_comments_node_links'), + type: 'POST', + data: {'node_ids[]': nodeIDs, 'field_name': fieldName}, + dataType: 'json', + success: render + }); + } + } + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/comment/js/node-new-comments-link.js b/core/modules/comment/js/node-new-comments-link.js index 291bcbd11613..ae3cd121f094 100644 --- a/core/modules/comment/js/node-new-comments-link.js +++ b/core/modules/comment/js/node-new-comments-link.js @@ -1,117 +1,56 @@ /** - * @file - * Attaches behaviors for the Comment module's "X new comments" link. - * - * May only be loaded for authenticated users, with the History module - * installed. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/comment/js/node-new-comments-link.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Render "X new comments" links wherever necessary. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches new comment links behavior. - */ Drupal.behaviors.nodeNewCommentsLink = { - attach: function (context) { - // Collect all "X new comments" node link placeholders (and their - // corresponding node IDs) newer than 30 days ago that have not already - // been read after their last comment timestamp. + attach: function attach(context) { var nodeIDs = []; - var $placeholders = $(context) - .find('[data-history-node-last-comment-timestamp]') - .once('history') - .filter(function () { - var $placeholder = $(this); - var lastCommentTimestamp = parseInt($placeholder.attr('data-history-node-last-comment-timestamp'), 10); - var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id'); - if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) { - nodeIDs.push(nodeID); - // Hide this placeholder link until it is certain we'll need it. - hide($placeholder); - return true; - } - else { - // Remove this placeholder link from the DOM because we won't need - // it. - remove($placeholder); - return false; - } - }); + var $placeholders = $(context).find('[data-history-node-last-comment-timestamp]').once('history').filter(function () { + var $placeholder = $(this); + var lastCommentTimestamp = parseInt($placeholder.attr('data-history-node-last-comment-timestamp'), 10); + var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) { + nodeIDs.push(nodeID); + + hide($placeholder); + return true; + } else { + remove($placeholder); + return false; + } + }); if ($placeholders.length === 0) { return; } - // Perform an AJAX request to retrieve node read timestamps. Drupal.history.fetchTimestamps(nodeIDs, function () { processNodeNewCommentLinks($placeholders); }); } }; - /** - * Hides a "new comment" element. - * - * @param {jQuery} $placeholder - * The placeholder element of the new comment link. - * - * @return {jQuery} - * The placeholder element passed in as a parameter. - */ function hide($placeholder) { - return $placeholder - // Find the parent <li>. - .closest('.comment-new-comments') - // Find the preceding <li>, if any, and give it the 'last' class. - .prev().addClass('last') - // Go back to the parent <li> and hide it. - .end().hide(); + return $placeholder.closest('.comment-new-comments').prev().addClass('last').end().hide(); } - /** - * Removes a "new comment" element. - * - * @param {jQuery} $placeholder - * The placeholder element of the new comment link. - */ function remove($placeholder) { hide($placeholder).remove(); } - /** - * Shows a "new comment" element. - * - * @param {jQuery} $placeholder - * The placeholder element of the new comment link. - * - * @return {jQuery} - * The placeholder element passed in as a parameter. - */ function show($placeholder) { - return $placeholder - // Find the parent <li>. - .closest('.comment-new-comments') - // Find the preceding <li>, if any, and remove its 'last' class, if any. - .prev().removeClass('last') - // Go back to the parent <li> and show it. - .end().show(); + return $placeholder.closest('.comment-new-comments').prev().removeClass('last').end().show(); } - /** - * Processes new comment links and adds appropriate text in relevant cases. - * - * @param {jQuery} $placeholders - * The placeholder elements of the current page. - */ function processNodeNewCommentLinks($placeholders) { - // Figure out which placeholders need the "x new comments" links. var $placeholdersToUpdate = {}; var fieldName = 'comment'; var $placeholder; @@ -122,39 +61,22 @@ var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id'); var lastViewTimestamp = Drupal.history.getLastRead(nodeID); - // Queue this placeholder's "X new comments" link to be downloaded from - // the server. if (timestamp > lastViewTimestamp) { $placeholdersToUpdate[nodeID] = $placeholder; - } - // No "X new comments" link necessary; remove it from the DOM. - else { - remove($placeholder); - } + } else { + remove($placeholder); + } }); - // Perform an AJAX request to retrieve node view timestamps. var nodeIDs = Object.keys($placeholdersToUpdate); if (nodeIDs.length === 0) { return; } - /** - * Renders the "X new comments" links. - * - * Either use the data embedded in the page or perform an AJAX request to - * retrieve the same data. - * - * @param {object} results - * Data about new comment links indexed by nodeID. - */ function render(results) { for (var nodeID in results) { if (results.hasOwnProperty(nodeID) && $placeholdersToUpdate.hasOwnProperty(nodeID)) { - $placeholdersToUpdate[nodeID] - .attr('href', results[nodeID].first_new_comment_link) - .text(Drupal.formatPlural(results[nodeID].new_comment_count, '1 new comment', '@count new comments')) - .removeClass('hidden'); + $placeholdersToUpdate[nodeID].attr('href', results[nodeID].first_new_comment_link).text(Drupal.formatPlural(results[nodeID].new_comment_count, '1 new comment', '@count new comments')).removeClass('hidden'); show($placeholdersToUpdate[nodeID]); } } @@ -162,16 +84,14 @@ if (drupalSettings.comment && drupalSettings.comment.newCommentsLinks) { render(drupalSettings.comment.newCommentsLinks.node[fieldName]); - } - else { + } else { $.ajax({ url: Drupal.url('comments/render_new_comments_node_links'), type: 'POST', - data: {'node_ids[]': nodeIDs, 'field_name': fieldName}, + data: { 'node_ids[]': nodeIDs, 'field_name': fieldName }, dataType: 'json', success: render }); } } - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/content_translation/content_translation.admin.es6.js b/core/modules/content_translation/content_translation.admin.es6.js new file mode 100644 index 000000000000..fcbf699f7382 --- /dev/null +++ b/core/modules/content_translation/content_translation.admin.es6.js @@ -0,0 +1,131 @@ +/** + * @file + * Content Translation admin behaviors. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Forces applicable options to be checked as translatable. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches content translation dependent options to the UI. + */ + Drupal.behaviors.contentTranslationDependentOptions = { + attach: function (context) { + var $context = $(context); + var options = drupalSettings.contentTranslationDependentOptions; + var $fields; + var dependent_columns; + + function fieldsChangeHandler($fields, dependent_columns) { + return function (e) { + Drupal.behaviors.contentTranslationDependentOptions.check($fields, dependent_columns, $(e.target)); + }; + } + + // We're given a generic name to look for so we find all inputs containing + // that name and copy over the input values that require all columns to be + // translatable. + if (options && options.dependent_selectors) { + for (var field in options.dependent_selectors) { + if (options.dependent_selectors.hasOwnProperty(field)) { + $fields = $context.find('input[name^="' + field + '"]'); + dependent_columns = options.dependent_selectors[field]; + + $fields.on('change', fieldsChangeHandler($fields, dependent_columns)); + Drupal.behaviors.contentTranslationDependentOptions.check($fields, dependent_columns); + } + } + } + }, + check: function ($fields, dependent_columns, $changed) { + var $element = $changed; + var column; + + function filterFieldsList(index, field) { + return $(field).val() === column; + } + + // A field that has many different translatable parts can also define one + // or more columns that require all columns to be translatable. + for (var index in dependent_columns) { + if (dependent_columns.hasOwnProperty(index)) { + column = dependent_columns[index]; + + if (!$changed) { + $element = $fields.filter(filterFieldsList); + } + + if ($element.is('input[value="' + column + '"]:checked')) { + $fields.prop('checked', true) + .not($element).prop('disabled', true); + } + else { + $fields.prop('disabled', false); + } + + } + } + } + }; + + /** + * Makes field translatability inherit bundle translatability. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches content translation behavior. + */ + Drupal.behaviors.contentTranslation = { + attach: function (context) { + // Initially hide all field rows for non translatable bundles and all + // column rows for non translatable fields. + $(context).find('table .bundle-settings .translatable :input').once('translation-entity-admin-hide').each(function () { + var $input = $(this); + var $bundleSettings = $input.closest('.bundle-settings'); + if (!$input.is(':checked')) { + $bundleSettings.nextUntil('.bundle-settings').hide(); + } + else { + $bundleSettings.nextUntil('.bundle-settings', '.field-settings').find('.translatable :input:not(:checked)').closest('.field-settings').nextUntil(':not(.column-settings)').hide(); + } + }); + + // When a bundle is made translatable all of its fields should inherit + // this setting. Instead when it is made non translatable its fields are + // hidden, since their translatability no longer matters. + $('body').once('translation-entity-admin-bind').on('click', 'table .bundle-settings .translatable :input', function (e) { + var $target = $(e.target); + var $bundleSettings = $target.closest('.bundle-settings'); + var $settings = $bundleSettings.nextUntil('.bundle-settings'); + var $fieldSettings = $settings.filter('.field-settings'); + if ($target.is(':checked')) { + $bundleSettings.find('.operations :input[name$="[language_alterable]"]').prop('checked', true); + $fieldSettings.find('.translatable :input').prop('checked', true); + $settings.show(); + } + else { + $settings.hide(); + } + }) + .on('click', 'table .field-settings .translatable :input', function (e) { + var $target = $(e.target); + var $fieldSettings = $target.closest('.field-settings'); + var $columnSettings = $fieldSettings.nextUntil('.field-settings, .bundle-settings'); + if ($target.is(':checked')) { + $columnSettings.show(); + } + else { + $columnSettings.hide(); + } + }); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/content_translation/content_translation.admin.js b/core/modules/content_translation/content_translation.admin.js index fcbf699f7382..74ef925b77f2 100644 --- a/core/modules/content_translation/content_translation.admin.js +++ b/core/modules/content_translation/content_translation.admin.js @@ -1,22 +1,17 @@ /** - * @file - * Content Translation admin behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/content_translation/content_translation.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Forces applicable options to be checked as translatable. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches content translation dependent options to the UI. - */ Drupal.behaviors.contentTranslationDependentOptions = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); var options = drupalSettings.contentTranslationDependentOptions; var $fields; @@ -28,9 +23,6 @@ }; } - // We're given a generic name to look for so we find all inputs containing - // that name and copy over the input values that require all columns to be - // translatable. if (options && options.dependent_selectors) { for (var field in options.dependent_selectors) { if (options.dependent_selectors.hasOwnProperty(field)) { @@ -43,7 +35,7 @@ } } }, - check: function ($fields, dependent_columns, $changed) { + check: function check($fields, dependent_columns, $changed) { var $element = $changed; var column; @@ -51,8 +43,6 @@ return $(field).val() === column; } - // A field that has many different translatable parts can also define one - // or more columns that require all columns to be translatable. for (var index in dependent_columns) { if (dependent_columns.hasOwnProperty(index)) { column = dependent_columns[index]; @@ -62,44 +52,27 @@ } if ($element.is('input[value="' + column + '"]:checked')) { - $fields.prop('checked', true) - .not($element).prop('disabled', true); - } - else { + $fields.prop('checked', true).not($element).prop('disabled', true); + } else { $fields.prop('disabled', false); } - } } } }; - /** - * Makes field translatability inherit bundle translatability. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches content translation behavior. - */ Drupal.behaviors.contentTranslation = { - attach: function (context) { - // Initially hide all field rows for non translatable bundles and all - // column rows for non translatable fields. + attach: function attach(context) { $(context).find('table .bundle-settings .translatable :input').once('translation-entity-admin-hide').each(function () { var $input = $(this); var $bundleSettings = $input.closest('.bundle-settings'); if (!$input.is(':checked')) { $bundleSettings.nextUntil('.bundle-settings').hide(); - } - else { + } else { $bundleSettings.nextUntil('.bundle-settings', '.field-settings').find('.translatable :input:not(:checked)').closest('.field-settings').nextUntil(':not(.column-settings)').hide(); } }); - // When a bundle is made translatable all of its fields should inherit - // this setting. Instead when it is made non translatable its fields are - // hidden, since their translatability no longer matters. $('body').once('translation-entity-admin-bind').on('click', 'table .bundle-settings .translatable :input', function (e) { var $target = $(e.target); var $bundleSettings = $target.closest('.bundle-settings'); @@ -109,23 +82,19 @@ $bundleSettings.find('.operations :input[name$="[language_alterable]"]').prop('checked', true); $fieldSettings.find('.translatable :input').prop('checked', true); $settings.show(); - } - else { + } else { $settings.hide(); } - }) - .on('click', 'table .field-settings .translatable :input', function (e) { - var $target = $(e.target); - var $fieldSettings = $target.closest('.field-settings'); - var $columnSettings = $fieldSettings.nextUntil('.field-settings, .bundle-settings'); - if ($target.is(':checked')) { - $columnSettings.show(); - } - else { - $columnSettings.hide(); - } - }); + }).on('click', 'table .field-settings .translatable :input', function (e) { + var $target = $(e.target); + var $fieldSettings = $target.closest('.field-settings'); + var $columnSettings = $fieldSettings.nextUntil('.field-settings, .bundle-settings'); + if ($target.is(':checked')) { + $columnSettings.show(); + } else { + $columnSettings.hide(); + } + }); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/contextual/js/contextual.es6.js b/core/modules/contextual/js/contextual.es6.js new file mode 100644 index 000000000000..558ea105cc2f --- /dev/null +++ b/core/modules/contextual/js/contextual.es6.js @@ -0,0 +1,256 @@ +/** + * @file + * Attaches behaviors for the Contextual module. + */ + +(function ($, Drupal, drupalSettings, _, Backbone, JSON, storage) { + + 'use strict'; + + var options = $.extend(drupalSettings.contextual, + // Merge strings on top of drupalSettings so that they are not mutable. + { + strings: { + open: Drupal.t('Open'), + close: Drupal.t('Close') + } + } + ); + + // Clear the cached contextual links whenever the current user's set of + // permissions changes. + var cachedPermissionsHash = storage.getItem('Drupal.contextual.permissionsHash'); + var permissionsHash = drupalSettings.user.permissionsHash; + if (cachedPermissionsHash !== permissionsHash) { + if (typeof permissionsHash === 'string') { + _.chain(storage).keys().each(function (key) { + if (key.substring(0, 18) === 'Drupal.contextual.') { + storage.removeItem(key); + } + }); + } + storage.setItem('Drupal.contextual.permissionsHash', permissionsHash); + } + + /** + * Initializes a contextual link: updates its DOM, sets up model and views. + * + * @param {jQuery} $contextual + * A contextual links placeholder DOM element, containing the actual + * contextual links as rendered by the server. + * @param {string} html + * The server-side rendered HTML for this contextual link. + */ + function initContextual($contextual, html) { + var $region = $contextual.closest('.contextual-region'); + var contextual = Drupal.contextual; + + $contextual + // Update the placeholder to contain its rendered contextual links. + .html(html) + // Use the placeholder as a wrapper with a specific class to provide + // positioning and behavior attachment context. + .addClass('contextual') + // Ensure a trigger element exists before the actual contextual links. + .prepend(Drupal.theme('contextualTrigger')); + + // Set the destination parameter on each of the contextual links. + var destination = 'destination=' + Drupal.encodePath(drupalSettings.path.currentPath); + $contextual.find('.contextual-links a').each(function () { + var url = this.getAttribute('href'); + var glue = (url.indexOf('?') === -1) ? '?' : '&'; + this.setAttribute('href', url + glue + destination); + }); + + // Create a model and the appropriate views. + var model = new contextual.StateModel({ + title: $region.find('h2').eq(0).text().trim() + }); + var viewOptions = $.extend({el: $contextual, model: model}, options); + contextual.views.push({ + visual: new contextual.VisualView(viewOptions), + aural: new contextual.AuralView(viewOptions), + keyboard: new contextual.KeyboardView(viewOptions) + }); + contextual.regionViews.push(new contextual.RegionView( + $.extend({el: $region, model: model}, options)) + ); + + // Add the model to the collection. This must happen after the views have + // been associated with it, otherwise collection change event handlers can't + // trigger the model change event handler in its views. + contextual.collection.add(model); + + // Let other JavaScript react to the adding of a new contextual link. + $(document).trigger('drupalContextualLinkAdded', { + $el: $contextual, + $region: $region, + model: model + }); + + // Fix visual collisions between contextual link triggers. + adjustIfNestedAndOverlapping($contextual); + } + + /** + * Determines if a contextual link is nested & overlapping, if so: adjusts it. + * + * This only deals with two levels of nesting; deeper levels are not touched. + * + * @param {jQuery} $contextual + * A contextual links placeholder DOM element, containing the actual + * contextual links as rendered by the server. + */ + function adjustIfNestedAndOverlapping($contextual) { + var $contextuals = $contextual + // @todo confirm that .closest() is not sufficient + .parents('.contextual-region').eq(-1) + .find('.contextual'); + + // Early-return when there's no nesting. + if ($contextuals.length === 1) { + return; + } + + // If the two contextual links overlap, then we move the second one. + var firstTop = $contextuals.eq(0).offset().top; + var secondTop = $contextuals.eq(1).offset().top; + if (firstTop === secondTop) { + var $nestedContextual = $contextuals.eq(1); + + // Retrieve height of nested contextual link. + var height = 0; + var $trigger = $nestedContextual.find('.trigger'); + // Elements with the .visually-hidden class have no dimensions, so this + // class must be temporarily removed to the calculate the height. + $trigger.removeClass('visually-hidden'); + height = $nestedContextual.height(); + $trigger.addClass('visually-hidden'); + + // Adjust nested contextual link's position. + $nestedContextual.css({top: $nestedContextual.position().top + height}); + } + } + + /** + * Attaches outline behavior for regions associated with contextual links. + * + * Events + * Contextual triggers an event that can be used by other scripts. + * - drupalContextualLinkAdded: Triggered when a contextual link is added. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the outline behavior to the right context. + */ + Drupal.behaviors.contextual = { + attach: function (context) { + var $context = $(context); + + // Find all contextual links placeholders, if any. + var $placeholders = $context.find('[data-contextual-id]').once('contextual-render'); + if ($placeholders.length === 0) { + return; + } + + // Collect the IDs for all contextual links placeholders. + var ids = []; + $placeholders.each(function () { + ids.push($(this).attr('data-contextual-id')); + }); + + // Update all contextual links placeholders whose HTML is cached. + var uncachedIDs = _.filter(ids, function initIfCached(contextualID) { + var html = storage.getItem('Drupal.contextual.' + contextualID); + if (html && html.length) { + // Initialize after the current execution cycle, to make the AJAX + // request for retrieving the uncached contextual links as soon as + // possible, but also to ensure that other Drupal behaviors have had + // the chance to set up an event listener on the Backbone collection + // Drupal.contextual.collection. + window.setTimeout(function () { + initContextual($context.find('[data-contextual-id="' + contextualID + '"]'), html); + }); + return false; + } + return true; + }); + + // Perform an AJAX request to let the server render the contextual links + // for each of the placeholders. + if (uncachedIDs.length > 0) { + $.ajax({ + url: Drupal.url('contextual/render'), + type: 'POST', + data: {'ids[]': uncachedIDs}, + dataType: 'json', + success: function (results) { + _.each(results, function (html, contextualID) { + // Store the metadata. + storage.setItem('Drupal.contextual.' + contextualID, html); + // If the rendered contextual links are empty, then the current + // user does not have permission to access the associated links: + // don't render anything. + if (html.length > 0) { + // Update the placeholders to contain its rendered contextual + // links. Usually there will only be one placeholder, but it's + // possible for multiple identical placeholders exist on the + // page (probably because the same content appears more than + // once). + $placeholders = $context.find('[data-contextual-id="' + contextualID + '"]'); + + // Initialize the contextual links. + for (var i = 0; i < $placeholders.length; i++) { + initContextual($placeholders.eq(i), html); + } + } + }); + } + }); + } + } + }; + + /** + * Namespace for contextual related functionality. + * + * @namespace + */ + Drupal.contextual = { + + /** + * The {@link Drupal.contextual.View} instances associated with each list + * element of contextual links. + * + * @type {Array} + */ + views: [], + + /** + * The {@link Drupal.contextual.RegionView} instances associated with each + * contextual region element. + * + * @type {Array} + */ + regionViews: [] + }; + + /** + * A Backbone.Collection of {@link Drupal.contextual.StateModel} instances. + * + * @type {Backbone.Collection} + */ + Drupal.contextual.collection = new Backbone.Collection([], {model: Drupal.contextual.StateModel}); + + /** + * A trigger is an interactive element often bound to a click handler. + * + * @return {string} + * A string representing a DOM fragment. + */ + Drupal.theme.contextualTrigger = function () { + return '<button class="trigger visually-hidden focusable" type="button"></button>'; + }; + +})(jQuery, Drupal, drupalSettings, _, Backbone, window.JSON, window.sessionStorage); diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js index 558ea105cc2f..7eb42ec3de0d 100644 --- a/core/modules/contextual/js/contextual.js +++ b/core/modules/contextual/js/contextual.js @@ -1,24 +1,22 @@ /** - * @file - * Attaches behaviors for the Contextual module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/contextual.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings, _, Backbone, JSON, storage) { 'use strict'; - var options = $.extend(drupalSettings.contextual, - // Merge strings on top of drupalSettings so that they are not mutable. - { - strings: { - open: Drupal.t('Open'), - close: Drupal.t('Close') - } + var options = $.extend(drupalSettings.contextual, { + strings: { + open: Drupal.t('Open'), + close: Drupal.t('Close') } - ); + }); - // Clear the cached contextual links whenever the current user's set of - // permissions changes. var cachedPermissionsHash = storage.getItem('Drupal.contextual.permissionsHash'); var permissionsHash = drupalSettings.user.permissionsHash; if (cachedPermissionsHash !== permissionsHash) { @@ -32,143 +30,81 @@ storage.setItem('Drupal.contextual.permissionsHash', permissionsHash); } - /** - * Initializes a contextual link: updates its DOM, sets up model and views. - * - * @param {jQuery} $contextual - * A contextual links placeholder DOM element, containing the actual - * contextual links as rendered by the server. - * @param {string} html - * The server-side rendered HTML for this contextual link. - */ function initContextual($contextual, html) { var $region = $contextual.closest('.contextual-region'); var contextual = Drupal.contextual; - $contextual - // Update the placeholder to contain its rendered contextual links. - .html(html) - // Use the placeholder as a wrapper with a specific class to provide - // positioning and behavior attachment context. - .addClass('contextual') - // Ensure a trigger element exists before the actual contextual links. - .prepend(Drupal.theme('contextualTrigger')); + $contextual.html(html).addClass('contextual').prepend(Drupal.theme('contextualTrigger')); - // Set the destination parameter on each of the contextual links. var destination = 'destination=' + Drupal.encodePath(drupalSettings.path.currentPath); $contextual.find('.contextual-links a').each(function () { var url = this.getAttribute('href'); - var glue = (url.indexOf('?') === -1) ? '?' : '&'; + var glue = url.indexOf('?') === -1 ? '?' : '&'; this.setAttribute('href', url + glue + destination); }); - // Create a model and the appropriate views. var model = new contextual.StateModel({ title: $region.find('h2').eq(0).text().trim() }); - var viewOptions = $.extend({el: $contextual, model: model}, options); + var viewOptions = $.extend({ el: $contextual, model: model }, options); contextual.views.push({ visual: new contextual.VisualView(viewOptions), aural: new contextual.AuralView(viewOptions), keyboard: new contextual.KeyboardView(viewOptions) }); - contextual.regionViews.push(new contextual.RegionView( - $.extend({el: $region, model: model}, options)) - ); + contextual.regionViews.push(new contextual.RegionView($.extend({ el: $region, model: model }, options))); - // Add the model to the collection. This must happen after the views have - // been associated with it, otherwise collection change event handlers can't - // trigger the model change event handler in its views. contextual.collection.add(model); - // Let other JavaScript react to the adding of a new contextual link. $(document).trigger('drupalContextualLinkAdded', { $el: $contextual, $region: $region, model: model }); - // Fix visual collisions between contextual link triggers. adjustIfNestedAndOverlapping($contextual); } - /** - * Determines if a contextual link is nested & overlapping, if so: adjusts it. - * - * This only deals with two levels of nesting; deeper levels are not touched. - * - * @param {jQuery} $contextual - * A contextual links placeholder DOM element, containing the actual - * contextual links as rendered by the server. - */ function adjustIfNestedAndOverlapping($contextual) { - var $contextuals = $contextual - // @todo confirm that .closest() is not sufficient - .parents('.contextual-region').eq(-1) - .find('.contextual'); + var $contextuals = $contextual.parents('.contextual-region').eq(-1).find('.contextual'); - // Early-return when there's no nesting. if ($contextuals.length === 1) { return; } - // If the two contextual links overlap, then we move the second one. var firstTop = $contextuals.eq(0).offset().top; var secondTop = $contextuals.eq(1).offset().top; if (firstTop === secondTop) { var $nestedContextual = $contextuals.eq(1); - // Retrieve height of nested contextual link. var height = 0; var $trigger = $nestedContextual.find('.trigger'); - // Elements with the .visually-hidden class have no dimensions, so this - // class must be temporarily removed to the calculate the height. + $trigger.removeClass('visually-hidden'); height = $nestedContextual.height(); $trigger.addClass('visually-hidden'); - // Adjust nested contextual link's position. - $nestedContextual.css({top: $nestedContextual.position().top + height}); + $nestedContextual.css({ top: $nestedContextual.position().top + height }); } } - /** - * Attaches outline behavior for regions associated with contextual links. - * - * Events - * Contextual triggers an event that can be used by other scripts. - * - drupalContextualLinkAdded: Triggered when a contextual link is added. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the outline behavior to the right context. - */ Drupal.behaviors.contextual = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); - // Find all contextual links placeholders, if any. var $placeholders = $context.find('[data-contextual-id]').once('contextual-render'); if ($placeholders.length === 0) { return; } - // Collect the IDs for all contextual links placeholders. var ids = []; $placeholders.each(function () { ids.push($(this).attr('data-contextual-id')); }); - // Update all contextual links placeholders whose HTML is cached. var uncachedIDs = _.filter(ids, function initIfCached(contextualID) { var html = storage.getItem('Drupal.contextual.' + contextualID); if (html && html.length) { - // Initialize after the current execution cycle, to make the AJAX - // request for retrieving the uncached contextual links as soon as - // possible, but also to ensure that other Drupal behaviors have had - // the chance to set up an event listener on the Backbone collection - // Drupal.contextual.collection. window.setTimeout(function () { initContextual($context.find('[data-contextual-id="' + contextualID + '"]'), html); }); @@ -177,30 +113,19 @@ return true; }); - // Perform an AJAX request to let the server render the contextual links - // for each of the placeholders. if (uncachedIDs.length > 0) { $.ajax({ url: Drupal.url('contextual/render'), type: 'POST', - data: {'ids[]': uncachedIDs}, + data: { 'ids[]': uncachedIDs }, dataType: 'json', - success: function (results) { + success: function success(results) { _.each(results, function (html, contextualID) { - // Store the metadata. storage.setItem('Drupal.contextual.' + contextualID, html); - // If the rendered contextual links are empty, then the current - // user does not have permission to access the associated links: - // don't render anything. + if (html.length > 0) { - // Update the placeholders to contain its rendered contextual - // links. Usually there will only be one placeholder, but it's - // possible for multiple identical placeholders exist on the - // page (probably because the same content appears more than - // once). $placeholders = $context.find('[data-contextual-id="' + contextualID + '"]'); - // Initialize the contextual links. for (var i = 0; i < $placeholders.length; i++) { initContextual($placeholders.eq(i), html); } @@ -212,45 +137,15 @@ } }; - /** - * Namespace for contextual related functionality. - * - * @namespace - */ Drupal.contextual = { - - /** - * The {@link Drupal.contextual.View} instances associated with each list - * element of contextual links. - * - * @type {Array} - */ views: [], - /** - * The {@link Drupal.contextual.RegionView} instances associated with each - * contextual region element. - * - * @type {Array} - */ regionViews: [] }; - /** - * A Backbone.Collection of {@link Drupal.contextual.StateModel} instances. - * - * @type {Backbone.Collection} - */ - Drupal.contextual.collection = new Backbone.Collection([], {model: Drupal.contextual.StateModel}); + Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.StateModel }); - /** - * A trigger is an interactive element often bound to a click handler. - * - * @return {string} - * A string representing a DOM fragment. - */ Drupal.theme.contextualTrigger = function () { return '<button class="trigger visually-hidden focusable" type="button"></button>'; }; - -})(jQuery, Drupal, drupalSettings, _, Backbone, window.JSON, window.sessionStorage); +})(jQuery, Drupal, drupalSettings, _, Backbone, window.JSON, window.sessionStorage); \ No newline at end of file diff --git a/core/modules/contextual/js/contextual.toolbar.es6.js b/core/modules/contextual/js/contextual.toolbar.es6.js new file mode 100644 index 000000000000..b5a9053490c0 --- /dev/null +++ b/core/modules/contextual/js/contextual.toolbar.es6.js @@ -0,0 +1,77 @@ +/** + * @file + * Attaches behaviors for the Contextual module's edit toolbar tab. + */ + +(function ($, Drupal, Backbone) { + + 'use strict'; + + var strings = { + tabbingReleased: Drupal.t('Tabbing is no longer constrained by the Contextual module.'), + tabbingConstrained: Drupal.t('Tabbing is constrained to a set of @contextualsCount and the edit mode toggle.'), + pressEsc: Drupal.t('Press the esc key to exit.') + }; + + /** + * Initializes a contextual link: updates its DOM, sets up model and views. + * + * @param {HTMLElement} context + * A contextual links DOM element as rendered by the server. + */ + function initContextualToolbar(context) { + if (!Drupal.contextual || !Drupal.contextual.collection) { + return; + } + + var contextualToolbar = Drupal.contextualToolbar; + var model = contextualToolbar.model = new contextualToolbar.StateModel({ + // Checks whether localStorage indicates we should start in edit mode + // rather than view mode. + // @see Drupal.contextualToolbar.VisualView.persist + isViewing: localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false' + }, { + contextualCollection: Drupal.contextual.collection + }); + + var viewOptions = { + el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'), + model: model, + strings: strings + }; + new contextualToolbar.VisualView(viewOptions); + new contextualToolbar.AuralView(viewOptions); + } + + /** + * Attaches contextual's edit toolbar tab behavior. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches contextual toolbar behavior on a contextualToolbar-init event. + */ + Drupal.behaviors.contextualToolbar = { + attach: function (context) { + if ($('body').once('contextualToolbar-init').length) { + initContextualToolbar(context); + } + } + }; + + /** + * Namespace for the contextual toolbar. + * + * @namespace + */ + Drupal.contextualToolbar = { + + /** + * The {@link Drupal.contextualToolbar.StateModel} instance. + * + * @type {?Drupal.contextualToolbar.StateModel} + */ + model: null + }; + +})(jQuery, Drupal, Backbone); diff --git a/core/modules/contextual/js/contextual.toolbar.js b/core/modules/contextual/js/contextual.toolbar.js index b5a9053490c0..291a5a68c300 100644 --- a/core/modules/contextual/js/contextual.toolbar.js +++ b/core/modules/contextual/js/contextual.toolbar.js @@ -1,7 +1,10 @@ /** - * @file - * Attaches behaviors for the Contextual module's edit toolbar tab. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/contextual.toolbar.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, Backbone) { @@ -13,12 +16,6 @@ pressEsc: Drupal.t('Press the esc key to exit.') }; - /** - * Initializes a contextual link: updates its DOM, sets up model and views. - * - * @param {HTMLElement} context - * A contextual links DOM element as rendered by the server. - */ function initContextualToolbar(context) { if (!Drupal.contextual || !Drupal.contextual.collection) { return; @@ -26,9 +23,6 @@ var contextualToolbar = Drupal.contextualToolbar; var model = contextualToolbar.model = new contextualToolbar.StateModel({ - // Checks whether localStorage indicates we should start in edit mode - // rather than view mode. - // @see Drupal.contextualToolbar.VisualView.persist isViewing: localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false' }, { contextualCollection: Drupal.contextual.collection @@ -43,35 +37,15 @@ new contextualToolbar.AuralView(viewOptions); } - /** - * Attaches contextual's edit toolbar tab behavior. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches contextual toolbar behavior on a contextualToolbar-init event. - */ Drupal.behaviors.contextualToolbar = { - attach: function (context) { + attach: function attach(context) { if ($('body').once('contextualToolbar-init').length) { initContextualToolbar(context); } } }; - /** - * Namespace for the contextual toolbar. - * - * @namespace - */ Drupal.contextualToolbar = { - - /** - * The {@link Drupal.contextualToolbar.StateModel} instance. - * - * @type {?Drupal.contextualToolbar.StateModel} - */ model: null }; - -})(jQuery, Drupal, Backbone); +})(jQuery, Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/contextual/js/models/StateModel.es6.js b/core/modules/contextual/js/models/StateModel.es6.js new file mode 100644 index 000000000000..465d717d5636 --- /dev/null +++ b/core/modules/contextual/js/models/StateModel.es6.js @@ -0,0 +1,132 @@ +/** + * @file + * A Backbone Model for the state of a contextual link's trigger, list & region. + */ + +(function (Drupal, Backbone) { + + 'use strict'; + + /** + * Models the state of a contextual link's trigger, list & region. + * + * @constructor + * + * @augments Backbone.Model + */ + Drupal.contextual.StateModel = Backbone.Model.extend(/** @lends Drupal.contextual.StateModel# */{ + + /** + * @type {object} + * + * @prop {string} title + * @prop {bool} regionIsHovered + * @prop {bool} hasFocus + * @prop {bool} isOpen + * @prop {bool} isLocked + */ + defaults: /** @lends Drupal.contextual.StateModel# */{ + + /** + * The title of the entity to which these contextual links apply. + * + * @type {string} + */ + title: '', + + /** + * Represents if the contextual region is being hovered. + * + * @type {bool} + */ + regionIsHovered: false, + + /** + * Represents if the contextual trigger or options have focus. + * + * @type {bool} + */ + hasFocus: false, + + /** + * Represents if the contextual options for an entity are available to + * be selected (i.e. whether the list of options is visible). + * + * @type {bool} + */ + isOpen: false, + + /** + * When the model is locked, the trigger remains active. + * + * @type {bool} + */ + isLocked: false + }, + + /** + * Opens or closes the contextual link. + * + * If it is opened, then also give focus. + * + * @return {Drupal.contextual.StateModel} + * The current contextual state model. + */ + toggleOpen: function () { + var newIsOpen = !this.get('isOpen'); + this.set('isOpen', newIsOpen); + if (newIsOpen) { + this.focus(); + } + return this; + }, + + /** + * Closes this contextual link. + * + * Does not call blur() because we want to allow a contextual link to have + * focus, yet be closed for example when hovering. + * + * @return {Drupal.contextual.StateModel} + * The current contextual state model. + */ + close: function () { + this.set('isOpen', false); + return this; + }, + + /** + * Gives focus to this contextual link. + * + * Also closes + removes focus from every other contextual link. + * + * @return {Drupal.contextual.StateModel} + * The current contextual state model. + */ + focus: function () { + this.set('hasFocus', true); + var cid = this.cid; + this.collection.each(function (model) { + if (model.cid !== cid) { + model.close().blur(); + } + }); + return this; + }, + + /** + * Removes focus from this contextual link, unless it is open. + * + * @return {Drupal.contextual.StateModel} + * The current contextual state model. + */ + blur: function () { + if (!this.get('isOpen')) { + this.set('hasFocus', false); + } + return this; + } + + }); + +})(Drupal, Backbone); diff --git a/core/modules/contextual/js/models/StateModel.js b/core/modules/contextual/js/models/StateModel.js index 465d717d5636..ac9a89582486 100644 --- a/core/modules/contextual/js/models/StateModel.js +++ b/core/modules/contextual/js/models/StateModel.js @@ -1,78 +1,29 @@ /** - * @file - * A Backbone Model for the state of a contextual link's trigger, list & region. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/models/StateModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone) { 'use strict'; - /** - * Models the state of a contextual link's trigger, list & region. - * - * @constructor - * - * @augments Backbone.Model - */ - Drupal.contextual.StateModel = Backbone.Model.extend(/** @lends Drupal.contextual.StateModel# */{ - - /** - * @type {object} - * - * @prop {string} title - * @prop {bool} regionIsHovered - * @prop {bool} hasFocus - * @prop {bool} isOpen - * @prop {bool} isLocked - */ - defaults: /** @lends Drupal.contextual.StateModel# */{ - - /** - * The title of the entity to which these contextual links apply. - * - * @type {string} - */ + Drupal.contextual.StateModel = Backbone.Model.extend({ + defaults: { title: '', - /** - * Represents if the contextual region is being hovered. - * - * @type {bool} - */ regionIsHovered: false, - /** - * Represents if the contextual trigger or options have focus. - * - * @type {bool} - */ hasFocus: false, - /** - * Represents if the contextual options for an entity are available to - * be selected (i.e. whether the list of options is visible). - * - * @type {bool} - */ isOpen: false, - /** - * When the model is locked, the trigger remains active. - * - * @type {bool} - */ isLocked: false }, - /** - * Opens or closes the contextual link. - * - * If it is opened, then also give focus. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - toggleOpen: function () { + toggleOpen: function toggleOpen() { var newIsOpen = !this.get('isOpen'); this.set('isOpen', newIsOpen); if (newIsOpen) { @@ -81,29 +32,12 @@ return this; }, - /** - * Closes this contextual link. - * - * Does not call blur() because we want to allow a contextual link to have - * focus, yet be closed for example when hovering. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - close: function () { + close: function close() { this.set('isOpen', false); return this; }, - /** - * Gives focus to this contextual link. - * - * Also closes + removes focus from every other contextual link. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - focus: function () { + focus: function focus() { this.set('hasFocus', true); var cid = this.cid; this.collection.each(function (model) { @@ -114,13 +48,7 @@ return this; }, - /** - * Removes focus from this contextual link, unless it is open. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - blur: function () { + blur: function blur() { if (!this.get('isOpen')) { this.set('hasFocus', false); } @@ -128,5 +56,4 @@ } }); - -})(Drupal, Backbone); +})(Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/contextual/js/toolbar/models/StateModel.es6.js b/core/modules/contextual/js/toolbar/models/StateModel.es6.js new file mode 100644 index 000000000000..d9159e4e97f6 --- /dev/null +++ b/core/modules/contextual/js/toolbar/models/StateModel.es6.js @@ -0,0 +1,119 @@ +/** + * @file + * A Backbone Model for the state of Contextual module's edit toolbar tab. + */ + +(function (Drupal, Backbone) { + + 'use strict'; + + Drupal.contextualToolbar.StateModel = Backbone.Model.extend(/** @lends Drupal.contextualToolbar.StateModel# */{ + + /** + * @type {object} + * + * @prop {bool} isViewing + * @prop {bool} isVisible + * @prop {number} contextualCount + * @prop {Drupal~TabbingContext} tabbingContext + */ + defaults: /** @lends Drupal.contextualToolbar.StateModel# */{ + + /** + * Indicates whether the toggle is currently in "view" or "edit" mode. + * + * @type {bool} + */ + isViewing: true, + + /** + * Indicates whether the toggle should be visible or hidden. Automatically + * calculated, depends on contextualCount. + * + * @type {bool} + */ + isVisible: false, + + /** + * Tracks how many contextual links exist on the page. + * + * @type {number} + */ + contextualCount: 0, + + /** + * A TabbingContext object as returned by {@link Drupal~TabbingManager}: + * the set of tabbable elements when edit mode is enabled. + * + * @type {?Drupal~TabbingContext} + */ + tabbingContext: null + }, + + /** + * Models the state of the edit mode toggle. + * + * @constructs + * + * @augments Backbone.Model + * + * @param {object} attrs + * Attributes for the backbone model. + * @param {object} options + * An object with the following option: + * @param {Backbone.collection} options.contextualCollection + * The collection of {@link Drupal.contextual.StateModel} models that + * represent the contextual links on the page. + */ + initialize: function (attrs, options) { + // Respond to new/removed contextual links. + this.listenTo(options.contextualCollection, 'reset remove add', this.countContextualLinks); + this.listenTo(options.contextualCollection, 'add', this.lockNewContextualLinks); + + // Automatically determine visibility. + this.listenTo(this, 'change:contextualCount', this.updateVisibility); + + // Whenever edit mode is toggled, lock all contextual links. + this.listenTo(this, 'change:isViewing', function (model, isViewing) { + options.contextualCollection.each(function (contextualModel) { + contextualModel.set('isLocked', !isViewing); + }); + }); + }, + + /** + * Tracks the number of contextual link models in the collection. + * + * @param {Drupal.contextual.StateModel} contextualModel + * The contextual links model that was added or removed. + * @param {Backbone.Collection} contextualCollection + * The collection of contextual link models. + */ + countContextualLinks: function (contextualModel, contextualCollection) { + this.set('contextualCount', contextualCollection.length); + }, + + /** + * Lock newly added contextual links if edit mode is enabled. + * + * @param {Drupal.contextual.StateModel} contextualModel + * The contextual links model that was added. + * @param {Backbone.Collection} [contextualCollection] + * The collection of contextual link models. + */ + lockNewContextualLinks: function (contextualModel, contextualCollection) { + if (!this.get('isViewing')) { + contextualModel.set('isLocked', true); + } + }, + + /** + * Automatically updates visibility of the view/edit mode toggle. + */ + updateVisibility: function () { + this.set('isVisible', this.get('contextualCount') > 0); + } + + }); + +})(Drupal, Backbone); diff --git a/core/modules/contextual/js/toolbar/models/StateModel.js b/core/modules/contextual/js/toolbar/models/StateModel.js index d9159e4e97f6..93eed97b5b55 100644 --- a/core/modules/contextual/js/toolbar/models/StateModel.js +++ b/core/modules/contextual/js/toolbar/models/StateModel.js @@ -1,79 +1,32 @@ /** - * @file - * A Backbone Model for the state of Contextual module's edit toolbar tab. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/toolbar/models/StateModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone) { 'use strict'; - Drupal.contextualToolbar.StateModel = Backbone.Model.extend(/** @lends Drupal.contextualToolbar.StateModel# */{ - - /** - * @type {object} - * - * @prop {bool} isViewing - * @prop {bool} isVisible - * @prop {number} contextualCount - * @prop {Drupal~TabbingContext} tabbingContext - */ - defaults: /** @lends Drupal.contextualToolbar.StateModel# */{ - - /** - * Indicates whether the toggle is currently in "view" or "edit" mode. - * - * @type {bool} - */ + Drupal.contextualToolbar.StateModel = Backbone.Model.extend({ + defaults: { isViewing: true, - /** - * Indicates whether the toggle should be visible or hidden. Automatically - * calculated, depends on contextualCount. - * - * @type {bool} - */ isVisible: false, - /** - * Tracks how many contextual links exist on the page. - * - * @type {number} - */ contextualCount: 0, - /** - * A TabbingContext object as returned by {@link Drupal~TabbingManager}: - * the set of tabbable elements when edit mode is enabled. - * - * @type {?Drupal~TabbingContext} - */ tabbingContext: null }, - /** - * Models the state of the edit mode toggle. - * - * @constructs - * - * @augments Backbone.Model - * - * @param {object} attrs - * Attributes for the backbone model. - * @param {object} options - * An object with the following option: - * @param {Backbone.collection} options.contextualCollection - * The collection of {@link Drupal.contextual.StateModel} models that - * represent the contextual links on the page. - */ - initialize: function (attrs, options) { - // Respond to new/removed contextual links. + initialize: function initialize(attrs, options) { this.listenTo(options.contextualCollection, 'reset remove add', this.countContextualLinks); this.listenTo(options.contextualCollection, 'add', this.lockNewContextualLinks); - // Automatically determine visibility. this.listenTo(this, 'change:contextualCount', this.updateVisibility); - // Whenever edit mode is toggled, lock all contextual links. this.listenTo(this, 'change:isViewing', function (model, isViewing) { options.contextualCollection.each(function (contextualModel) { contextualModel.set('isLocked', !isViewing); @@ -81,39 +34,19 @@ }); }, - /** - * Tracks the number of contextual link models in the collection. - * - * @param {Drupal.contextual.StateModel} contextualModel - * The contextual links model that was added or removed. - * @param {Backbone.Collection} contextualCollection - * The collection of contextual link models. - */ - countContextualLinks: function (contextualModel, contextualCollection) { + countContextualLinks: function countContextualLinks(contextualModel, contextualCollection) { this.set('contextualCount', contextualCollection.length); }, - /** - * Lock newly added contextual links if edit mode is enabled. - * - * @param {Drupal.contextual.StateModel} contextualModel - * The contextual links model that was added. - * @param {Backbone.Collection} [contextualCollection] - * The collection of contextual link models. - */ - lockNewContextualLinks: function (contextualModel, contextualCollection) { + lockNewContextualLinks: function lockNewContextualLinks(contextualModel, contextualCollection) { if (!this.get('isViewing')) { contextualModel.set('isLocked', true); } }, - /** - * Automatically updates visibility of the view/edit mode toggle. - */ - updateVisibility: function () { + updateVisibility: function updateVisibility() { this.set('isVisible', this.get('contextualCount') > 0); } }); - -})(Drupal, Backbone); +})(Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/contextual/js/toolbar/views/AuralView.es6.js b/core/modules/contextual/js/toolbar/views/AuralView.es6.js new file mode 100644 index 000000000000..d684ffb9e63e --- /dev/null +++ b/core/modules/contextual/js/toolbar/views/AuralView.es6.js @@ -0,0 +1,104 @@ +/** + * @file + * A Backbone View that provides the aural view of the edit mode toggle. + */ + +(function ($, Drupal, Backbone, _) { + + 'use strict'; + + Drupal.contextualToolbar.AuralView = Backbone.View.extend(/** @lends Drupal.contextualToolbar.AuralView# */{ + + /** + * Tracks whether the tabbing constraint announcement has been read once. + * + * @type {bool} + */ + announcedOnce: false, + + /** + * Renders the aural view of the edit mode toggle (screen reader support). + * + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * Options for the view. + */ + initialize: function (options) { + this.options = options; + + this.listenTo(this.model, 'change', this.render); + this.listenTo(this.model, 'change:isViewing', this.manageTabbing); + + $(document).on('keyup', _.bind(this.onKeypress, this)); + }, + + /** + * @inheritdoc + * + * @return {Drupal.contextualToolbar.AuralView} + * The current contextual toolbar aural view. + */ + render: function () { + // Render the state. + this.$el.find('button').attr('aria-pressed', !this.model.get('isViewing')); + + return this; + }, + + /** + * Limits tabbing to the contextual links and edit mode toolbar tab. + */ + manageTabbing: function () { + var tabbingContext = this.model.get('tabbingContext'); + // Always release an existing tabbing context. + if (tabbingContext) { + tabbingContext.release(); + Drupal.announce(this.options.strings.tabbingReleased); + } + // Create a new tabbing context when edit mode is enabled. + if (!this.model.get('isViewing')) { + tabbingContext = Drupal.tabbingManager.constrain($('.contextual-toolbar-tab, .contextual')); + this.model.set('tabbingContext', tabbingContext); + this.announceTabbingConstraint(); + this.announcedOnce = true; + } + }, + + /** + * Announces the current tabbing constraint. + */ + announceTabbingConstraint: function () { + var strings = this.options.strings; + Drupal.announce(Drupal.formatString(strings.tabbingConstrained, { + '@contextualsCount': Drupal.formatPlural(Drupal.contextual.collection.length, '@count contextual link', '@count contextual links') + })); + Drupal.announce(strings.pressEsc); + }, + + /** + * Responds to esc and tab key press events. + * + * @param {jQuery.Event} event + * The keypress event. + */ + onKeypress: function (event) { + // The first tab key press is tracked so that an annoucement about tabbing + // constraints can be raised if edit mode is enabled when the page is + // loaded. + if (!this.announcedOnce && event.keyCode === 9 && !this.model.get('isViewing')) { + this.announceTabbingConstraint(); + // Set announce to true so that this conditional block won't run again. + this.announcedOnce = true; + } + // Respond to the ESC key. Exit out of edit mode. + if (event.keyCode === 27) { + this.model.set('isViewing', true); + } + } + + }); + +})(jQuery, Drupal, Backbone, _); diff --git a/core/modules/contextual/js/toolbar/views/AuralView.js b/core/modules/contextual/js/toolbar/views/AuralView.js index d684ffb9e63e..132342f5801e 100644 --- a/core/modules/contextual/js/toolbar/views/AuralView.js +++ b/core/modules/contextual/js/toolbar/views/AuralView.js @@ -1,32 +1,19 @@ /** - * @file - * A Backbone View that provides the aural view of the edit mode toggle. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/toolbar/views/AuralView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, Backbone, _) { 'use strict'; - Drupal.contextualToolbar.AuralView = Backbone.View.extend(/** @lends Drupal.contextualToolbar.AuralView# */{ - - /** - * Tracks whether the tabbing constraint announcement has been read once. - * - * @type {bool} - */ + Drupal.contextualToolbar.AuralView = Backbone.View.extend({ announcedOnce: false, - /** - * Renders the aural view of the edit mode toggle (screen reader support). - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options for the view. - */ - initialize: function (options) { + initialize: function initialize(options) { this.options = options; this.listenTo(this.model, 'change', this.render); @@ -35,30 +22,20 @@ $(document).on('keyup', _.bind(this.onKeypress, this)); }, - /** - * @inheritdoc - * - * @return {Drupal.contextualToolbar.AuralView} - * The current contextual toolbar aural view. - */ - render: function () { - // Render the state. + render: function render() { this.$el.find('button').attr('aria-pressed', !this.model.get('isViewing')); return this; }, - /** - * Limits tabbing to the contextual links and edit mode toolbar tab. - */ - manageTabbing: function () { + manageTabbing: function manageTabbing() { var tabbingContext = this.model.get('tabbingContext'); - // Always release an existing tabbing context. + if (tabbingContext) { tabbingContext.release(); Drupal.announce(this.options.strings.tabbingReleased); } - // Create a new tabbing context when edit mode is enabled. + if (!this.model.get('isViewing')) { tabbingContext = Drupal.tabbingManager.constrain($('.contextual-toolbar-tab, .contextual')); this.model.set('tabbingContext', tabbingContext); @@ -67,10 +44,7 @@ } }, - /** - * Announces the current tabbing constraint. - */ - announceTabbingConstraint: function () { + announceTabbingConstraint: function announceTabbingConstraint() { var strings = this.options.strings; Drupal.announce(Drupal.formatString(strings.tabbingConstrained, { '@contextualsCount': Drupal.formatPlural(Drupal.contextual.collection.length, '@count contextual link', '@count contextual links') @@ -78,27 +52,17 @@ Drupal.announce(strings.pressEsc); }, - /** - * Responds to esc and tab key press events. - * - * @param {jQuery.Event} event - * The keypress event. - */ - onKeypress: function (event) { - // The first tab key press is tracked so that an annoucement about tabbing - // constraints can be raised if edit mode is enabled when the page is - // loaded. + onKeypress: function onKeypress(event) { if (!this.announcedOnce && event.keyCode === 9 && !this.model.get('isViewing')) { this.announceTabbingConstraint(); - // Set announce to true so that this conditional block won't run again. + this.announcedOnce = true; } - // Respond to the ESC key. Exit out of edit mode. + if (event.keyCode === 27) { this.model.set('isViewing', true); } } }); - -})(jQuery, Drupal, Backbone, _); +})(jQuery, Drupal, Backbone, _); \ No newline at end of file diff --git a/core/modules/contextual/js/toolbar/views/VisualView.es6.js b/core/modules/contextual/js/toolbar/views/VisualView.es6.js new file mode 100644 index 000000000000..d1d413502d32 --- /dev/null +++ b/core/modules/contextual/js/toolbar/views/VisualView.es6.js @@ -0,0 +1,84 @@ +/** + * @file + * A Backbone View that provides the visual view of the edit mode toggle. + */ + +(function (Drupal, Backbone) { + + 'use strict'; + + Drupal.contextualToolbar.VisualView = Backbone.View.extend(/** @lends Drupal.contextualToolbar.VisualView# */{ + + /** + * Events for the Backbone view. + * + * @return {object} + * A mapping of events to be used in the view. + */ + events: function () { + // Prevents delay and simulated mouse events. + var touchEndToClick = function (event) { + event.preventDefault(); + event.target.click(); + }; + + return { + click: function () { + this.model.set('isViewing', !this.model.get('isViewing')); + }, + touchend: touchEndToClick + }; + }, + + /** + * Renders the visual view of the edit mode toggle. + * + * Listens to mouse & touch and handles edit mode toggle interactions. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.listenTo(this.model, 'change', this.render); + this.listenTo(this.model, 'change:isViewing', this.persist); + }, + + /** + * @inheritdoc + * + * @return {Drupal.contextualToolbar.VisualView} + * The current contextual toolbar visual view. + */ + render: function () { + // Render the visibility. + this.$el.toggleClass('hidden', !this.model.get('isVisible')); + // Render the state. + this.$el.find('button').toggleClass('is-active', !this.model.get('isViewing')); + + return this; + }, + + /** + * Model change handler; persists the isViewing value to localStorage. + * + * `isViewing === true` is the default, so only stores in localStorage when + * it's not the default value (i.e. false). + * + * @param {Drupal.contextualToolbar.StateModel} model + * A {@link Drupal.contextualToolbar.StateModel} model. + * @param {bool} isViewing + * The value of the isViewing attribute in the model. + */ + persist: function (model, isViewing) { + if (!isViewing) { + localStorage.setItem('Drupal.contextualToolbar.isViewing', 'false'); + } + else { + localStorage.removeItem('Drupal.contextualToolbar.isViewing'); + } + } + + }); + +})(Drupal, Backbone); diff --git a/core/modules/contextual/js/toolbar/views/VisualView.js b/core/modules/contextual/js/toolbar/views/VisualView.js index d1d413502d32..f666a95c2180 100644 --- a/core/modules/contextual/js/toolbar/views/VisualView.js +++ b/core/modules/contextual/js/toolbar/views/VisualView.js @@ -1,84 +1,50 @@ /** - * @file - * A Backbone View that provides the visual view of the edit mode toggle. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/toolbar/views/VisualView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone) { 'use strict'; - Drupal.contextualToolbar.VisualView = Backbone.View.extend(/** @lends Drupal.contextualToolbar.VisualView# */{ - - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events: function () { - // Prevents delay and simulated mouse events. - var touchEndToClick = function (event) { + Drupal.contextualToolbar.VisualView = Backbone.View.extend({ + events: function events() { + var touchEndToClick = function touchEndToClick(event) { event.preventDefault(); event.target.click(); }; return { - click: function () { + click: function click() { this.model.set('isViewing', !this.model.get('isViewing')); }, touchend: touchEndToClick }; }, - /** - * Renders the visual view of the edit mode toggle. - * - * Listens to mouse & touch and handles edit mode toggle interactions. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + initialize: function initialize() { this.listenTo(this.model, 'change', this.render); this.listenTo(this.model, 'change:isViewing', this.persist); }, - /** - * @inheritdoc - * - * @return {Drupal.contextualToolbar.VisualView} - * The current contextual toolbar visual view. - */ - render: function () { - // Render the visibility. + render: function render() { this.$el.toggleClass('hidden', !this.model.get('isVisible')); - // Render the state. + this.$el.find('button').toggleClass('is-active', !this.model.get('isViewing')); return this; }, - /** - * Model change handler; persists the isViewing value to localStorage. - * - * `isViewing === true` is the default, so only stores in localStorage when - * it's not the default value (i.e. false). - * - * @param {Drupal.contextualToolbar.StateModel} model - * A {@link Drupal.contextualToolbar.StateModel} model. - * @param {bool} isViewing - * The value of the isViewing attribute in the model. - */ - persist: function (model, isViewing) { + persist: function persist(model, isViewing) { if (!isViewing) { localStorage.setItem('Drupal.contextualToolbar.isViewing', 'false'); - } - else { + } else { localStorage.removeItem('Drupal.contextualToolbar.isViewing'); } } }); - -})(Drupal, Backbone); +})(Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/contextual/js/views/AuralView.es6.js b/core/modules/contextual/js/views/AuralView.es6.js new file mode 100644 index 000000000000..8ba2e33e347b --- /dev/null +++ b/core/modules/contextual/js/views/AuralView.es6.js @@ -0,0 +1,55 @@ +/** + * @file + * A Backbone View that provides the aural view of a contextual link. + */ + +(function (Drupal, Backbone) { + + 'use strict'; + + Drupal.contextual.AuralView = Backbone.View.extend(/** @lends Drupal.contextual.AuralView# */{ + + /** + * Renders the aural view of a contextual link (i.e. screen reader support). + * + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * Options for the view. + */ + initialize: function (options) { + this.options = options; + + this.listenTo(this.model, 'change', this.render); + + // Use aria-role form so that the number of items in the list is spoken. + this.$el.attr('role', 'form'); + + // Initial render. + this.render(); + }, + + /** + * @inheritdoc + */ + render: function () { + var isOpen = this.model.get('isOpen'); + + // Set the hidden property of the links. + this.$el.find('.contextual-links') + .prop('hidden', !isOpen); + + // Update the view of the trigger. + this.$el.find('.trigger') + .text(Drupal.t('@action @title configuration options', { + '@action': (!isOpen) ? this.options.strings.open : this.options.strings.close, + '@title': this.model.get('title') + })) + .attr('aria-pressed', isOpen); + } + + }); + +})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/AuralView.js b/core/modules/contextual/js/views/AuralView.js index 8ba2e33e347b..5cbefcdd785d 100644 --- a/core/modules/contextual/js/views/AuralView.js +++ b/core/modules/contextual/js/views/AuralView.js @@ -1,55 +1,36 @@ /** - * @file - * A Backbone View that provides the aural view of a contextual link. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/views/AuralView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone) { 'use strict'; - Drupal.contextual.AuralView = Backbone.View.extend(/** @lends Drupal.contextual.AuralView# */{ - - /** - * Renders the aural view of a contextual link (i.e. screen reader support). - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options for the view. - */ - initialize: function (options) { + Drupal.contextual.AuralView = Backbone.View.extend({ + initialize: function initialize(options) { this.options = options; this.listenTo(this.model, 'change', this.render); - // Use aria-role form so that the number of items in the list is spoken. this.$el.attr('role', 'form'); - // Initial render. this.render(); }, - /** - * @inheritdoc - */ - render: function () { + render: function render() { var isOpen = this.model.get('isOpen'); - // Set the hidden property of the links. - this.$el.find('.contextual-links') - .prop('hidden', !isOpen); - - // Update the view of the trigger. - this.$el.find('.trigger') - .text(Drupal.t('@action @title configuration options', { - '@action': (!isOpen) ? this.options.strings.open : this.options.strings.close, - '@title': this.model.get('title') - })) - .attr('aria-pressed', isOpen); + this.$el.find('.contextual-links').prop('hidden', !isOpen); + + this.$el.find('.trigger').text(Drupal.t('@action @title configuration options', { + '@action': !isOpen ? this.options.strings.open : this.options.strings.close, + '@title': this.model.get('title') + })).attr('aria-pressed', isOpen); } }); - -})(Drupal, Backbone); +})(Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/contextual/js/views/KeyboardView.es6.js b/core/modules/contextual/js/views/KeyboardView.es6.js new file mode 100644 index 000000000000..9c247730e312 --- /dev/null +++ b/core/modules/contextual/js/views/KeyboardView.es6.js @@ -0,0 +1,61 @@ +/** + * @file + * A Backbone View that provides keyboard interaction for a contextual link. + */ + +(function (Drupal, Backbone) { + + 'use strict'; + + Drupal.contextual.KeyboardView = Backbone.View.extend(/** @lends Drupal.contextual.KeyboardView# */{ + + /** + * @type {object} + */ + events: { + 'focus .trigger': 'focus', + 'focus .contextual-links a': 'focus', + 'blur .trigger': function () { this.model.blur(); }, + 'blur .contextual-links a': function () { + // Set up a timeout to allow a user to tab between the trigger and the + // contextual links without the menu dismissing. + var that = this; + this.timer = window.setTimeout(function () { + that.model.close().blur(); + }, 150); + } + }, + + /** + * Provides keyboard interaction for a contextual link. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + + /** + * The timer is used to create a delay before dismissing the contextual + * links on blur. This is only necessary when keyboard users tab into + * contextual links without edit mode (i.e. without TabbingManager). + * That means that if we decide to disable tabbing of contextual links + * without edit mode, all this timer logic can go away. + * + * @type {NaN|number} + */ + this.timer = NaN; + }, + + /** + * Sets focus on the model; Clears the timer that dismisses the links. + */ + focus: function () { + // Clear the timeout that might have been set by blurring a link. + window.clearTimeout(this.timer); + this.model.focus(); + } + + }); + +})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/KeyboardView.js b/core/modules/contextual/js/views/KeyboardView.js index 9c247730e312..1539971d6833 100644 --- a/core/modules/contextual/js/views/KeyboardView.js +++ b/core/modules/contextual/js/views/KeyboardView.js @@ -1,24 +1,23 @@ /** - * @file - * A Backbone View that provides keyboard interaction for a contextual link. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/views/KeyboardView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone) { 'use strict'; - Drupal.contextual.KeyboardView = Backbone.View.extend(/** @lends Drupal.contextual.KeyboardView# */{ - - /** - * @type {object} - */ + Drupal.contextual.KeyboardView = Backbone.View.extend({ events: { 'focus .trigger': 'focus', 'focus .contextual-links a': 'focus', - 'blur .trigger': function () { this.model.blur(); }, - 'blur .contextual-links a': function () { - // Set up a timeout to allow a user to tab between the trigger and the - // contextual links without the menu dismissing. + 'blur .trigger': function blurTrigger() { + this.model.blur(); + }, + 'blur .contextual-links a': function blurContextualLinksA() { var that = this; this.timer = window.setTimeout(function () { that.model.close().blur(); @@ -26,36 +25,14 @@ } }, - /** - * Provides keyboard interaction for a contextual link. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { - - /** - * The timer is used to create a delay before dismissing the contextual - * links on blur. This is only necessary when keyboard users tab into - * contextual links without edit mode (i.e. without TabbingManager). - * That means that if we decide to disable tabbing of contextual links - * without edit mode, all this timer logic can go away. - * - * @type {NaN|number} - */ + initialize: function initialize() { this.timer = NaN; }, - /** - * Sets focus on the model; Clears the timer that dismisses the links. - */ - focus: function () { - // Clear the timeout that might have been set by blurring a link. + focus: function focus() { window.clearTimeout(this.timer); this.model.focus(); } }); - -})(Drupal, Backbone); +})(Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/contextual/js/views/RegionView.es6.js b/core/modules/contextual/js/views/RegionView.es6.js new file mode 100644 index 000000000000..e960db36bfd6 --- /dev/null +++ b/core/modules/contextual/js/views/RegionView.es6.js @@ -0,0 +1,57 @@ +/** + * @file + * A Backbone View that renders the visual view of a contextual region element. + */ + +(function (Drupal, Backbone, Modernizr) { + + 'use strict'; + + Drupal.contextual.RegionView = Backbone.View.extend(/** @lends Drupal.contextual.RegionView# */{ + + /** + * Events for the Backbone view. + * + * @return {object} + * A mapping of events to be used in the view. + */ + events: function () { + var mapping = { + mouseenter: function () { this.model.set('regionIsHovered', true); }, + mouseleave: function () { + this.model.close().blur().set('regionIsHovered', false); + } + }; + // We don't want mouse hover events on touch. + if (Modernizr.touchevents) { + mapping = {}; + } + return mapping; + }, + + /** + * Renders the visual view of a contextual region element. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.listenTo(this.model, 'change:hasFocus', this.render); + }, + + /** + * @inheritdoc + * + * @return {Drupal.contextual.RegionView} + * The current contextual region view. + */ + render: function () { + this.$el.toggleClass('focus', this.model.get('hasFocus')); + + return this; + } + + }); + +})(Drupal, Backbone, Modernizr); diff --git a/core/modules/contextual/js/views/RegionView.js b/core/modules/contextual/js/views/RegionView.js index e960db36bfd6..d37e10f3c283 100644 --- a/core/modules/contextual/js/views/RegionView.js +++ b/core/modules/contextual/js/views/RegionView.js @@ -1,57 +1,41 @@ /** - * @file - * A Backbone View that renders the visual view of a contextual region element. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/views/RegionView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone, Modernizr) { 'use strict'; - Drupal.contextual.RegionView = Backbone.View.extend(/** @lends Drupal.contextual.RegionView# */{ - - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events: function () { + Drupal.contextual.RegionView = Backbone.View.extend({ + events: function events() { var mapping = { - mouseenter: function () { this.model.set('regionIsHovered', true); }, - mouseleave: function () { + mouseenter: function mouseenter() { + this.model.set('regionIsHovered', true); + }, + mouseleave: function mouseleave() { this.model.close().blur().set('regionIsHovered', false); } }; - // We don't want mouse hover events on touch. + if (Modernizr.touchevents) { mapping = {}; } return mapping; }, - /** - * Renders the visual view of a contextual region element. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + initialize: function initialize() { this.listenTo(this.model, 'change:hasFocus', this.render); }, - /** - * @inheritdoc - * - * @return {Drupal.contextual.RegionView} - * The current contextual region view. - */ - render: function () { + render: function render() { this.$el.toggleClass('focus', this.model.get('hasFocus')); return this; } }); - -})(Drupal, Backbone, Modernizr); +})(Drupal, Backbone, Modernizr); \ No newline at end of file diff --git a/core/modules/contextual/js/views/VisualView.es6.js b/core/modules/contextual/js/views/VisualView.es6.js new file mode 100644 index 000000000000..b22bb373dd8d --- /dev/null +++ b/core/modules/contextual/js/views/VisualView.es6.js @@ -0,0 +1,80 @@ +/** + * @file + * A Backbone View that provides the visual view of a contextual link. + */ + +(function (Drupal, Backbone, Modernizr) { + + 'use strict'; + + Drupal.contextual.VisualView = Backbone.View.extend(/** @lends Drupal.contextual.VisualView# */{ + + /** + * Events for the Backbone view. + * + * @return {object} + * A mapping of events to be used in the view. + */ + events: function () { + // Prevents delay and simulated mouse events. + var touchEndToClick = function (event) { + event.preventDefault(); + event.target.click(); + }; + var mapping = { + 'click .trigger': function () { this.model.toggleOpen(); }, + 'touchend .trigger': touchEndToClick, + 'click .contextual-links a': function () { this.model.close().blur(); }, + 'touchend .contextual-links a': touchEndToClick + }; + // We only want mouse hover events on non-touch. + if (!Modernizr.touchevents) { + mapping.mouseenter = function () { this.model.focus(); }; + } + return mapping; + }, + + /** + * Renders the visual view of a contextual link. Listens to mouse & touch. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.listenTo(this.model, 'change', this.render); + }, + + /** + * @inheritdoc + * + * @return {Drupal.contextual.VisualView} + * The current contextual visual view. + */ + render: function () { + var isOpen = this.model.get('isOpen'); + // The trigger should be visible when: + // - the mouse hovered over the region, + // - the trigger is locked, + // - and for as long as the contextual menu is open. + var isVisible = this.model.get('isLocked') || this.model.get('regionIsHovered') || isOpen; + + this.$el + // The open state determines if the links are visible. + .toggleClass('open', isOpen) + // Update the visibility of the trigger. + .find('.trigger').toggleClass('visually-hidden', !isVisible); + + // Nested contextual region handling: hide any nested contextual triggers. + if ('isOpen' in this.model.changed) { + this.$el.closest('.contextual-region') + .find('.contextual .trigger:not(:first)') + .toggle(!isOpen); + } + + return this; + } + + }); + +})(Drupal, Backbone, Modernizr); diff --git a/core/modules/contextual/js/views/VisualView.js b/core/modules/contextual/js/views/VisualView.js index b22bb373dd8d..c54f50f87189 100644 --- a/core/modules/contextual/js/views/VisualView.js +++ b/core/modules/contextual/js/views/VisualView.js @@ -1,80 +1,57 @@ /** - * @file - * A Backbone View that provides the visual view of a contextual link. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/contextual/js/views/VisualView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, Backbone, Modernizr) { 'use strict'; - Drupal.contextual.VisualView = Backbone.View.extend(/** @lends Drupal.contextual.VisualView# */{ - - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events: function () { - // Prevents delay and simulated mouse events. - var touchEndToClick = function (event) { + Drupal.contextual.VisualView = Backbone.View.extend({ + events: function events() { + var touchEndToClick = function touchEndToClick(event) { event.preventDefault(); event.target.click(); }; var mapping = { - 'click .trigger': function () { this.model.toggleOpen(); }, + 'click .trigger': function clickTrigger() { + this.model.toggleOpen(); + }, 'touchend .trigger': touchEndToClick, - 'click .contextual-links a': function () { this.model.close().blur(); }, + 'click .contextual-links a': function clickContextualLinksA() { + this.model.close().blur(); + }, 'touchend .contextual-links a': touchEndToClick }; - // We only want mouse hover events on non-touch. + if (!Modernizr.touchevents) { - mapping.mouseenter = function () { this.model.focus(); }; + mapping.mouseenter = function () { + this.model.focus(); + }; } return mapping; }, - /** - * Renders the visual view of a contextual link. Listens to mouse & touch. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + initialize: function initialize() { this.listenTo(this.model, 'change', this.render); }, - /** - * @inheritdoc - * - * @return {Drupal.contextual.VisualView} - * The current contextual visual view. - */ - render: function () { + render: function render() { var isOpen = this.model.get('isOpen'); - // The trigger should be visible when: - // - the mouse hovered over the region, - // - the trigger is locked, - // - and for as long as the contextual menu is open. + var isVisible = this.model.get('isLocked') || this.model.get('regionIsHovered') || isOpen; - this.$el - // The open state determines if the links are visible. - .toggleClass('open', isOpen) - // Update the visibility of the trigger. - .find('.trigger').toggleClass('visually-hidden', !isVisible); + this.$el.toggleClass('open', isOpen).find('.trigger').toggleClass('visually-hidden', !isVisible); - // Nested contextual region handling: hide any nested contextual triggers. if ('isOpen' in this.model.changed) { - this.$el.closest('.contextual-region') - .find('.contextual .trigger:not(:first)') - .toggle(!isOpen); + this.$el.closest('.contextual-region').find('.contextual .trigger:not(:first)').toggle(!isOpen); } return this; } }); - -})(Drupal, Backbone, Modernizr); +})(Drupal, Backbone, Modernizr); \ No newline at end of file diff --git a/core/modules/editor/js/editor.admin.es6.js b/core/modules/editor/js/editor.admin.es6.js new file mode 100644 index 000000000000..1fdb35320248 --- /dev/null +++ b/core/modules/editor/js/editor.admin.es6.js @@ -0,0 +1,935 @@ +/** + * @file + * Provides a JavaScript API to broadcast text editor configuration changes. + * + * Filter implementations may listen to the drupalEditorFeatureAdded, + * drupalEditorFeatureRemoved, and drupalEditorFeatureRemoved events on document + * to automatically adjust their settings based on the editor configuration. + */ + +(function ($, _, Drupal, document) { + + 'use strict'; + + /** + * Editor configuration namespace. + * + * @namespace + */ + Drupal.editorConfiguration = { + + /** + * Must be called by a specific text editor's configuration whenever a + * feature is added by the user. + * + * Triggers the drupalEditorFeatureAdded event on the document, which + * receives a {@link Drupal.EditorFeature} object. + * + * @param {Drupal.EditorFeature} feature + * A text editor feature object. + * + * @fires event:drupalEditorFeatureAdded + */ + addedFeature: function (feature) { + $(document).trigger('drupalEditorFeatureAdded', feature); + }, + + /** + * Must be called by a specific text editor's configuration whenever a + * feature is removed by the user. + * + * Triggers the drupalEditorFeatureRemoved event on the document, which + * receives a {@link Drupal.EditorFeature} object. + * + * @param {Drupal.EditorFeature} feature + * A text editor feature object. + * + * @fires event:drupalEditorFeatureRemoved + */ + removedFeature: function (feature) { + $(document).trigger('drupalEditorFeatureRemoved', feature); + }, + + /** + * Must be called by a specific text editor's configuration whenever a + * feature is modified, i.e. has different rules. + * + * For example when the "Bold" button is configured to use the `<b>` tag + * instead of the `<strong>` tag. + * + * Triggers the drupalEditorFeatureModified event on the document, which + * receives a {@link Drupal.EditorFeature} object. + * + * @param {Drupal.EditorFeature} feature + * A text editor feature object. + * + * @fires event:drupalEditorFeatureModified + */ + modifiedFeature: function (feature) { + $(document).trigger('drupalEditorFeatureModified', feature); + }, + + /** + * May be called by a specific text editor's configuration whenever a + * feature is being added, to check whether it would require the filter + * settings to be updated. + * + * The canonical use case is when a text editor is being enabled: + * preferably + * this would not cause the filter settings to be changed; rather, the + * default set of buttons (features) for the text editor should adjust + * itself to not cause filter setting changes. + * + * Note: for filters to integrate with this functionality, it is necessary + * that they implement + * `Drupal.filterSettingsForEditors[filterID].getRules()`. + * + * @param {Drupal.EditorFeature} feature + * A text editor feature object. + * + * @return {bool} + * Whether the given feature is allowed by the current filters. + */ + featureIsAllowedByFilters: function (feature) { + + /** + * Generate the universe U of possible values that can result from the + * feature's rules' requirements. + * + * This generates an object of this form: + * var universe = { + * a: { + * 'touchedByAllowedPropertyRule': false, + * 'tag': false, + * 'attributes:href': false, + * 'classes:external': false, + * }, + * strong: { + * 'touchedByAllowedPropertyRule': false, + * 'tag': false, + * }, + * img: { + * 'touchedByAllowedPropertyRule': false, + * 'tag': false, + * 'attributes:src': false + * } + * }; + * + * In this example, the given text editor feature resulted in the above + * universe, which shows that it must be allowed to generate the a, + * strong and img tags. For the a tag, it must be able to set the "href" + * attribute and the "external" class. For the strong tag, no further + * properties are required. For the img tag, the "src" attribute is + * required. The "tag" key is used to track whether that tag was + * explicitly allowed by one of the filter's rules. The + * "touchedByAllowedPropertyRule" key is used for state tracking that is + * essential for filterStatusAllowsFeature() to be able to reason: when + * all of a filter's rules have been applied, and none of the forbidden + * rules matched (which would have resulted in early termination) yet the + * universe has not been made empty (which would be the end result if + * everything in the universe were explicitly allowed), then this piece + * of state data enables us to determine whether a tag whose properties + * were not all explicitly allowed are in fact still allowed, because its + * tag was explicitly allowed and there were no filter rules applying + * "allowed tag property value" restrictions for this particular tag. + * + * @param {object} feature + * The feature in question. + * + * @return {object} + * The universe generated. + * + * @see findPropertyValueOnTag() + * @see filterStatusAllowsFeature() + */ + function generateUniverseFromFeatureRequirements(feature) { + var properties = ['attributes', 'styles', 'classes']; + var universe = {}; + + for (var r = 0; r < feature.rules.length; r++) { + var featureRule = feature.rules[r]; + + // For each tag required by this feature rule, create a basic entry in + // the universe. + var requiredTags = featureRule.required.tags; + for (var t = 0; t < requiredTags.length; t++) { + universe[requiredTags[t]] = { + // Whether this tag was allowed or not. + tag: false, + // Whether any filter rule that applies to this tag had an allowed + // property rule. i.e. will become true if >=1 filter rule has >=1 + // allowed property rule. + touchedByAllowedPropertyRule: false, + // Analogous, but for forbidden property rule. + touchedBytouchedByForbiddenPropertyRule: false + }; + } + + // If no required properties are defined for this rule, we can move on + // to the next feature. + if (emptyProperties(featureRule.required)) { + continue; + } + + // Expand the existing universe, assume that each tags' property + // value is disallowed. If the filter rules allow everything in the + // feature's universe, then the feature is allowed. + for (var p = 0; p < properties.length; p++) { + var property = properties[p]; + for (var pv = 0; pv < featureRule.required[property].length; pv++) { + var propertyValue = featureRule.required[property]; + universe[requiredTags][property + ':' + propertyValue] = false; + } + } + } + + return universe; + } + + /** + * Provided a section of a feature or filter rule, checks if no property + * values are defined for all properties: attributes, classes and styles. + * + * @param {object} section + * The section to check. + * + * @return {bool} + * Returns true if the section has empty properties, false otherwise. + */ + function emptyProperties(section) { + return section.attributes.length === 0 && section.classes.length === 0 && section.styles.length === 0; + } + + /** + * Calls findPropertyValueOnTag on the given tag for every property value + * that is listed in the "propertyValues" parameter. Supports the wildcard + * tag. + * + * @param {object} universe + * The universe to check. + * @param {string} tag + * The tag to look for. + * @param {string} property + * The property to check. + * @param {Array} propertyValues + * Values of the property to check. + * @param {bool} allowing + * Whether to update the universe or not. + * + * @return {bool} + * Returns true if found, false otherwise. + */ + function findPropertyValuesOnTag(universe, tag, property, propertyValues, allowing) { + // Detect the wildcard case. + if (tag === '*') { + return findPropertyValuesOnAllTags(universe, property, propertyValues, allowing); + } + + var atLeastOneFound = false; + _.each(propertyValues, function (propertyValue) { + if (findPropertyValueOnTag(universe, tag, property, propertyValue, allowing)) { + atLeastOneFound = true; + } + }); + return atLeastOneFound; + } + + /** + * Calls findPropertyValuesOnAllTags for all tags in the universe. + * + * @param {object} universe + * The universe to check. + * @param {string} property + * The property to check. + * @param {Array} propertyValues + * Values of the property to check. + * @param {bool} allowing + * Whether to update the universe or not. + * + * @return {bool} + * Returns true if found, false otherwise. + */ + function findPropertyValuesOnAllTags(universe, property, propertyValues, allowing) { + var atLeastOneFound = false; + _.each(_.keys(universe), function (tag) { + if (findPropertyValuesOnTag(universe, tag, property, propertyValues, allowing)) { + atLeastOneFound = true; + } + }); + return atLeastOneFound; + } + + /** + * Finds out if a specific property value (potentially containing + * wildcards) exists on the given tag. When the "allowing" parameter + * equals true, the universe will be updated if that specific property + * value exists. Returns true if found, false otherwise. + * + * @param {object} universe + * The universe to check. + * @param {string} tag + * The tag to look for. + * @param {string} property + * The property to check. + * @param {string} propertyValue + * The property value to check. + * @param {bool} allowing + * Whether to update the universe or not. + * + * @return {bool} + * Returns true if found, false otherwise. + */ + function findPropertyValueOnTag(universe, tag, property, propertyValue, allowing) { + // If the tag does not exist in the universe, then it definitely can't + // have this specific property value. + if (!_.has(universe, tag)) { + return false; + } + + var key = property + ':' + propertyValue; + + // Track whether a tag was touched by a filter rule that allows specific + // property values on this particular tag. + // @see generateUniverseFromFeatureRequirements + if (allowing) { + universe[tag].touchedByAllowedPropertyRule = true; + } + + // The simple case: no wildcard in property value. + if (_.indexOf(propertyValue, '*') === -1) { + if (_.has(universe, tag) && _.has(universe[tag], key)) { + if (allowing) { + universe[tag][key] = true; + } + return true; + } + return false; + } + // The complex case: wildcard in property value. + else { + var atLeastOneFound = false; + var regex = key.replace(/\*/g, '[^ ]*'); + _.each(_.keys(universe[tag]), function (key) { + if (key.match(regex)) { + atLeastOneFound = true; + if (allowing) { + universe[tag][key] = true; + } + } + }); + return atLeastOneFound; + } + } + + /** + * Deletes a tag from the universe if the tag itself and each of its + * properties are marked as allowed. + * + * @param {object} universe + * The universe to delete from. + * @param {string} tag + * The tag to check. + * + * @return {bool} + * Whether something was deleted from the universe. + */ + function deleteFromUniverseIfAllowed(universe, tag) { + // Detect the wildcard case. + if (tag === '*') { + return deleteAllTagsFromUniverseIfAllowed(universe); + } + if (_.has(universe, tag) && _.every(_.omit(universe[tag], 'touchedByAllowedPropertyRule'))) { + delete universe[tag]; + return true; + } + return false; + } + + /** + * Calls deleteFromUniverseIfAllowed for all tags in the universe. + * + * @param {object} universe + * The universe to delete from. + * + * @return {bool} + * Whether something was deleted from the universe. + */ + function deleteAllTagsFromUniverseIfAllowed(universe) { + var atLeastOneDeleted = false; + _.each(_.keys(universe), function (tag) { + if (deleteFromUniverseIfAllowed(universe, tag)) { + atLeastOneDeleted = true; + } + }); + return atLeastOneDeleted; + } + + /** + * Checks if any filter rule forbids either a tag or a tag property value + * that exists in the universe. + * + * @param {object} universe + * Universe to check. + * @param {object} filterStatus + * Filter status to use for check. + * + * @return {bool} + * Whether any filter rule forbids something in the universe. + */ + function anyForbiddenFilterRuleMatches(universe, filterStatus) { + var properties = ['attributes', 'styles', 'classes']; + + // Check if a tag in the universe is forbidden. + var allRequiredTags = _.keys(universe); + var filterRule; + for (var i = 0; i < filterStatus.rules.length; i++) { + filterRule = filterStatus.rules[i]; + if (filterRule.allow === false) { + if (_.intersection(allRequiredTags, filterRule.tags).length > 0) { + return true; + } + } + } + + // Check if a property value of a tag in the universe is forbidden. + // For all filter rules… + for (var n = 0; n < filterStatus.rules.length; n++) { + filterRule = filterStatus.rules[n]; + // … if there are tags with restricted property values … + if (filterRule.restrictedTags.tags.length && !emptyProperties(filterRule.restrictedTags.forbidden)) { + // … for all those tags … + for (var j = 0; j < filterRule.restrictedTags.tags.length; j++) { + var tag = filterRule.restrictedTags.tags[j]; + // … then iterate over all properties … + for (var k = 0; k < properties.length; k++) { + var property = properties[k]; + // … and return true if just one of the forbidden property + // values for this tag and property is listed in the universe. + if (findPropertyValuesOnTag(universe, tag, property, filterRule.restrictedTags.forbidden[property], false)) { + return true; + } + } + } + } + } + + return false; + } + + /** + * Applies every filter rule's explicit allowing of a tag or a tag + * property value to the universe. Whenever both the tag and all of its + * required property values are marked as explicitly allowed, they are + * deleted from the universe. + * + * @param {object} universe + * Universe to delete from. + * @param {object} filterStatus + * The filter status in question. + */ + function markAllowedTagsAndPropertyValues(universe, filterStatus) { + var properties = ['attributes', 'styles', 'classes']; + + // Check if a tag in the universe is allowed. + var filterRule; + var tag; + for (var l = 0; !_.isEmpty(universe) && l < filterStatus.rules.length; l++) { + filterRule = filterStatus.rules[l]; + if (filterRule.allow === true) { + for (var m = 0; !_.isEmpty(universe) && m < filterRule.tags.length; m++) { + tag = filterRule.tags[m]; + if (_.has(universe, tag)) { + universe[tag].tag = true; + deleteFromUniverseIfAllowed(universe, tag); + } + } + } + } + + // Check if a property value of a tag in the universe is allowed. + // For all filter rules… + for (var i = 0; !_.isEmpty(universe) && i < filterStatus.rules.length; i++) { + filterRule = filterStatus.rules[i]; + // … if there are tags with restricted property values … + if (filterRule.restrictedTags.tags.length && !emptyProperties(filterRule.restrictedTags.allowed)) { + // … for all those tags … + for (var j = 0; !_.isEmpty(universe) && j < filterRule.restrictedTags.tags.length; j++) { + tag = filterRule.restrictedTags.tags[j]; + // … then iterate over all properties … + for (var k = 0; k < properties.length; k++) { + var property = properties[k]; + // … and try to delete this tag from the universe if just one + // of the allowed property values for this tag and property is + // listed in the universe. (Because everything might be allowed + // now.) + if (findPropertyValuesOnTag(universe, tag, property, filterRule.restrictedTags.allowed[property], true)) { + deleteFromUniverseIfAllowed(universe, tag); + } + } + } + } + } + } + + /** + * Checks whether the current status of a filter allows a specific feature + * by building the universe of potential values from the feature's + * requirements and then checking whether anything in the filter prevents + * that. + * + * @param {object} filterStatus + * The filter status in question. + * @param {object} feature + * The feature requested. + * + * @return {bool} + * Whether the current status of the filter allows specified feature. + * + * @see generateUniverseFromFeatureRequirements() + */ + function filterStatusAllowsFeature(filterStatus, feature) { + // An inactive filter by definition allows the feature. + if (!filterStatus.active) { + return true; + } + + // A feature that specifies no rules has no HTML requirements and is + // hence allowed by definition. + if (feature.rules.length === 0) { + return true; + } + + // Analogously for a filter that specifies no rules. + if (filterStatus.rules.length === 0) { + return true; + } + + // Generate the universe U of possible values that can result from the + // feature's rules' requirements. + var universe = generateUniverseFromFeatureRequirements(feature); + + // If anything that is in the universe (and is thus required by the + // feature) is forbidden by any of the filter's rules, then this filter + // does not allow this feature. + if (anyForbiddenFilterRuleMatches(universe, filterStatus)) { + return false; + } + + // Mark anything in the universe that is allowed by any of the filter's + // rules as allowed. If everything is explicitly allowed, then the + // universe will become empty. + markAllowedTagsAndPropertyValues(universe, filterStatus); + + // If there was at least one filter rule allowing tags, then everything + // in the universe must be allowed for this feature to be allowed, and + // thus by now it must be empty. However, it is still possible that the + // filter allows the feature, due to no rules for allowing tag property + // values and/or rules for forbidding tag property values. For details: + // see the comments below. + // @see generateUniverseFromFeatureRequirements() + if (_.some(_.pluck(filterStatus.rules, 'allow'))) { + // If the universe is empty, then everything was explicitly allowed + // and our job is done: this filter allows this feature! + if (_.isEmpty(universe)) { + return true; + } + // Otherwise, it is still possible that this feature is allowed. + else { + // Every tag must be explicitly allowed if there are filter rules + // doing tag whitelisting. + if (!_.every(_.pluck(universe, 'tag'))) { + return false; + } + // Every tag was explicitly allowed, but since the universe is not + // empty, one or more tag properties are disallowed. However, if + // only blacklisting of tag properties was applied to these tags, + // and no whitelisting was ever applied, then it's still fine: + // since none of the tag properties were blacklisted, we got to + // this point, and since no whitelisting was applied, it doesn't + // matter that the properties: this could never have happened + // anyway. It's only this late that we can know this for certain. + else { + var tags = _.keys(universe); + // Figure out if there was any rule applying whitelisting tag + // restrictions to each of the remaining tags. + for (var i = 0; i < tags.length; i++) { + var tag = tags[i]; + if (_.has(universe, tag)) { + if (universe[tag].touchedByAllowedPropertyRule === false) { + delete universe[tag]; + } + } + } + return _.isEmpty(universe); + } + } + } + // Otherwise, if all filter rules were doing blacklisting, then the sole + // fact that we got to this point indicates that this filter allows for + // everything that is required for this feature. + else { + return true; + } + } + + // If any filter's current status forbids the editor feature, return + // false. + Drupal.filterConfiguration.update(); + for (var filterID in Drupal.filterConfiguration.statuses) { + if (Drupal.filterConfiguration.statuses.hasOwnProperty(filterID)) { + var filterStatus = Drupal.filterConfiguration.statuses[filterID]; + if (!(filterStatusAllowsFeature(filterStatus, feature))) { + return false; + } + } + } + + return true; + } + }; + + /** + * Constructor for an editor feature HTML rule. + * + * Intended to be used in combination with {@link Drupal.EditorFeature}. + * + * A text editor feature rule object describes both: + * - required HTML tags, attributes, styles and classes: without these, the + * text editor feature is unable to function. It's possible that a + * - allowed HTML tags, attributes, styles and classes: these are optional + * in the strictest sense, but it is possible that the feature generates + * them. + * + * The structure can be very clearly seen below: there's a "required" and an + * "allowed" key. For each of those, there are objects with the "tags", + * "attributes", "styles" and "classes" keys. For all these keys the values + * are initialized to the empty array. List each possible value as an array + * value. Besides the "required" and "allowed" keys, there's an optional + * "raw" key: it allows text editor implementations to optionally pass in + * their raw representation instead of the Drupal-defined representation for + * HTML rules. + * + * @example + * tags: ['<a>'] + * attributes: ['href', 'alt'] + * styles: ['color', 'text-decoration'] + * classes: ['external', 'internal'] + * + * @constructor + * + * @see Drupal.EditorFeature + */ + Drupal.EditorFeatureHTMLRule = function () { + + /** + * + * @type {object} + * + * @prop {Array} tags + * @prop {Array} attributes + * @prop {Array} styles + * @prop {Array} classes + */ + this.required = {tags: [], attributes: [], styles: [], classes: []}; + + /** + * + * @type {object} + * + * @prop {Array} tags + * @prop {Array} attributes + * @prop {Array} styles + * @prop {Array} classes + */ + this.allowed = {tags: [], attributes: [], styles: [], classes: []}; + + /** + * + * @type {null} + */ + this.raw = null; + }; + + /** + * A text editor feature object. Initialized with the feature name. + * + * Contains a set of HTML rules ({@link Drupal.EditorFeatureHTMLRule} objects) + * that describe which HTML tags, attributes, styles and classes are required + * (i.e. essential for the feature to function at all) and which are allowed + * (i.e. the feature may generate this, but they're not essential). + * + * It is necessary to allow for multiple HTML rules per feature: with just + * one HTML rule per feature, there is not enough expressiveness to describe + * certain cases. For example: a "table" feature would probably require the + * `<table>` tag, and might allow e.g. the "summary" attribute on that tag. + * However, the table feature would also require the `<tr>` and `<td>` tags, + * but it doesn't make sense to allow for a "summary" attribute on these tags. + * Hence these would need to be split in two separate rules. + * + * HTML rules must be added with the `addHTMLRule()` method. A feature that + * has zero HTML rules does not create or modify HTML. + * + * @constructor + * + * @param {string} name + * The name of the feature. + * + * @see Drupal.EditorFeatureHTMLRule + */ + Drupal.EditorFeature = function (name) { + this.name = name; + this.rules = []; + }; + + /** + * Adds a HTML rule to the list of HTML rules for this feature. + * + * @param {Drupal.EditorFeatureHTMLRule} rule + * A text editor feature HTML rule. + */ + Drupal.EditorFeature.prototype.addHTMLRule = function (rule) { + this.rules.push(rule); + }; + + /** + * Text filter status object. Initialized with the filter ID. + * + * Indicates whether the text filter is currently active (enabled) or not. + * + * Contains a set of HTML rules ({@link Drupal.FilterHTMLRule} objects) that + * describe which HTML tags are allowed or forbidden. They can also describe + * for a set of tags (or all tags) which attributes, styles and classes are + * allowed and which are forbidden. + * + * It is necessary to allow for multiple HTML rules per feature, for + * analogous reasons as {@link Drupal.EditorFeature}. + * + * HTML rules must be added with the `addHTMLRule()` method. A filter that has + * zero HTML rules does not disallow any HTML. + * + * @constructor + * + * @param {string} name + * The name of the feature. + * + * @see Drupal.FilterHTMLRule + */ + Drupal.FilterStatus = function (name) { + + /** + * + * @type {string} + */ + this.name = name; + + /** + * + * @type {bool} + */ + this.active = false; + + /** + * + * @type {Array.<Drupal.FilterHTMLRule>} + */ + this.rules = []; + }; + + /** + * Adds a HTML rule to the list of HTML rules for this filter. + * + * @param {Drupal.FilterHTMLRule} rule + * A text filter HTML rule. + */ + Drupal.FilterStatus.prototype.addHTMLRule = function (rule) { + this.rules.push(rule); + }; + + /** + * A text filter HTML rule object. + * + * Intended to be used in combination with {@link Drupal.FilterStatus}. + * + * A text filter rule object describes: + * 1. allowed or forbidden tags: (optional) whitelist or blacklist HTML tags + * 2. restricted tag properties: (optional) whitelist or blacklist + * attributes, styles and classes on a set of HTML tags. + * + * Typically, each text filter rule object does either 1 or 2, not both. + * + * The structure can be very clearly seen below: + * 1. use the "tags" key to list HTML tags, and set the "allow" key to + * either true (to allow these HTML tags) or false (to forbid these HTML + * tags). If you leave the "tags" key's default value (the empty array), + * no restrictions are applied. + * 2. all nested within the "restrictedTags" key: use the "tags" subkey to + * list HTML tags to which you want to apply property restrictions, then + * use the "allowed" subkey to whitelist specific property values, and + * similarly use the "forbidden" subkey to blacklist specific property + * values. + * + * @example + * <caption>Whitelist the "p", "strong" and "a" HTML tags.</caption> + * { + * tags: ['p', 'strong', 'a'], + * allow: true, + * restrictedTags: { + * tags: [], + * allowed: { attributes: [], styles: [], classes: [] }, + * forbidden: { attributes: [], styles: [], classes: [] } + * } + * } + * @example + * <caption>For the "a" HTML tag, only allow the "href" attribute + * and the "external" class and disallow the "target" attribute.</caption> + * { + * tags: [], + * allow: null, + * restrictedTags: { + * tags: ['a'], + * allowed: { attributes: ['href'], styles: [], classes: ['external'] }, + * forbidden: { attributes: ['target'], styles: [], classes: [] } + * } + * } + * @example + * <caption>For all tags, allow the "data-*" attribute (that is, any + * attribute that begins with "data-").</caption> + * { + * tags: [], + * allow: null, + * restrictedTags: { + * tags: ['*'], + * allowed: { attributes: ['data-*'], styles: [], classes: [] }, + * forbidden: { attributes: [], styles: [], classes: [] } + * } + * } + * + * @return {object} + * An object with the following structure: + * ``` + * { + * tags: Array, + * allow: null, + * restrictedTags: { + * tags: Array, + * allowed: {attributes: Array, styles: Array, classes: Array}, + * forbidden: {attributes: Array, styles: Array, classes: Array} + * } + * } + * ``` + * + * @see Drupal.FilterStatus + */ + Drupal.FilterHTMLRule = function () { + // Allow or forbid tags. + this.tags = []; + this.allow = null; + + // Apply restrictions to properties set on tags. + this.restrictedTags = { + tags: [], + allowed: {attributes: [], styles: [], classes: []}, + forbidden: {attributes: [], styles: [], classes: []} + }; + + return this; + }; + + Drupal.FilterHTMLRule.prototype.clone = function () { + var clone = new Drupal.FilterHTMLRule(); + clone.tags = this.tags.slice(0); + clone.allow = this.allow; + clone.restrictedTags.tags = this.restrictedTags.tags.slice(0); + clone.restrictedTags.allowed.attributes = this.restrictedTags.allowed.attributes.slice(0); + clone.restrictedTags.allowed.styles = this.restrictedTags.allowed.styles.slice(0); + clone.restrictedTags.allowed.classes = this.restrictedTags.allowed.classes.slice(0); + clone.restrictedTags.forbidden.attributes = this.restrictedTags.forbidden.attributes.slice(0); + clone.restrictedTags.forbidden.styles = this.restrictedTags.forbidden.styles.slice(0); + clone.restrictedTags.forbidden.classes = this.restrictedTags.forbidden.classes.slice(0); + return clone; + }; + + /** + * Tracks the configuration of all text filters in {@link Drupal.FilterStatus} + * objects for {@link Drupal.editorConfiguration.featureIsAllowedByFilters}. + * + * @namespace + */ + Drupal.filterConfiguration = { + + /** + * Drupal.FilterStatus objects, keyed by filter ID. + * + * @type {Object.<string, Drupal.FilterStatus>} + */ + statuses: {}, + + /** + * Live filter setting parsers. + * + * Object keyed by filter ID, for those filters that implement it. + * + * Filters should load the implementing JavaScript on the filter + * configuration form and implement + * `Drupal.filterSettings[filterID].getRules()`, which should return an + * array of {@link Drupal.FilterHTMLRule} objects. + * + * @namespace + */ + liveSettingParsers: {}, + + /** + * Updates all {@link Drupal.FilterStatus} objects to reflect current state. + * + * Automatically checks whether a filter is currently enabled or not. To + * support more finegrained. + * + * If a filter implements a live setting parser, then that will be used to + * keep the HTML rules for the {@link Drupal.FilterStatus} object + * up-to-date. + */ + update: function () { + for (var filterID in Drupal.filterConfiguration.statuses) { + if (Drupal.filterConfiguration.statuses.hasOwnProperty(filterID)) { + // Update status. + Drupal.filterConfiguration.statuses[filterID].active = $('[name="filters[' + filterID + '][status]"]').is(':checked'); + + // Update current rules. + if (Drupal.filterConfiguration.liveSettingParsers[filterID]) { + Drupal.filterConfiguration.statuses[filterID].rules = Drupal.filterConfiguration.liveSettingParsers[filterID].getRules(); + } + } + } + } + + }; + + /** + * Initializes {@link Drupal.filterConfiguration}. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Gets filter configuration from filter form input. + */ + Drupal.behaviors.initializeFilterConfiguration = { + attach: function (context, settings) { + var $context = $(context); + + $context.find('#filters-status-wrapper input.form-checkbox').once('filter-editor-status').each(function () { + var $checkbox = $(this); + var nameAttribute = $checkbox.attr('name'); + + // The filter's checkbox has a name attribute of the form + // "filters[<name of filter>][status]", parse "<name of filter>" + // from it. + var filterID = nameAttribute.substring(8, nameAttribute.indexOf(']')); + + // Create a Drupal.FilterStatus object to track the state (whether it's + // active or not and its current settings, if any) of each filter. + Drupal.filterConfiguration.statuses[filterID] = new Drupal.FilterStatus(filterID); + }); + } + }; + +})(jQuery, _, Drupal, document); diff --git a/core/modules/editor/js/editor.admin.js b/core/modules/editor/js/editor.admin.js index 1fdb35320248..d2ea26bd9e2e 100644 --- a/core/modules/editor/js/editor.admin.js +++ b/core/modules/editor/js/editor.admin.js @@ -1,147 +1,29 @@ /** - * @file - * Provides a JavaScript API to broadcast text editor configuration changes. - * - * Filter implementations may listen to the drupalEditorFeatureAdded, - * drupalEditorFeatureRemoved, and drupalEditorFeatureRemoved events on document - * to automatically adjust their settings based on the editor configuration. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/editor/js/editor.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, _, Drupal, document) { 'use strict'; - /** - * Editor configuration namespace. - * - * @namespace - */ Drupal.editorConfiguration = { - - /** - * Must be called by a specific text editor's configuration whenever a - * feature is added by the user. - * - * Triggers the drupalEditorFeatureAdded event on the document, which - * receives a {@link Drupal.EditorFeature} object. - * - * @param {Drupal.EditorFeature} feature - * A text editor feature object. - * - * @fires event:drupalEditorFeatureAdded - */ - addedFeature: function (feature) { + addedFeature: function addedFeature(feature) { $(document).trigger('drupalEditorFeatureAdded', feature); }, - /** - * Must be called by a specific text editor's configuration whenever a - * feature is removed by the user. - * - * Triggers the drupalEditorFeatureRemoved event on the document, which - * receives a {@link Drupal.EditorFeature} object. - * - * @param {Drupal.EditorFeature} feature - * A text editor feature object. - * - * @fires event:drupalEditorFeatureRemoved - */ - removedFeature: function (feature) { + removedFeature: function removedFeature(feature) { $(document).trigger('drupalEditorFeatureRemoved', feature); }, - /** - * Must be called by a specific text editor's configuration whenever a - * feature is modified, i.e. has different rules. - * - * For example when the "Bold" button is configured to use the `<b>` tag - * instead of the `<strong>` tag. - * - * Triggers the drupalEditorFeatureModified event on the document, which - * receives a {@link Drupal.EditorFeature} object. - * - * @param {Drupal.EditorFeature} feature - * A text editor feature object. - * - * @fires event:drupalEditorFeatureModified - */ - modifiedFeature: function (feature) { + modifiedFeature: function modifiedFeature(feature) { $(document).trigger('drupalEditorFeatureModified', feature); }, - /** - * May be called by a specific text editor's configuration whenever a - * feature is being added, to check whether it would require the filter - * settings to be updated. - * - * The canonical use case is when a text editor is being enabled: - * preferably - * this would not cause the filter settings to be changed; rather, the - * default set of buttons (features) for the text editor should adjust - * itself to not cause filter setting changes. - * - * Note: for filters to integrate with this functionality, it is necessary - * that they implement - * `Drupal.filterSettingsForEditors[filterID].getRules()`. - * - * @param {Drupal.EditorFeature} feature - * A text editor feature object. - * - * @return {bool} - * Whether the given feature is allowed by the current filters. - */ - featureIsAllowedByFilters: function (feature) { - - /** - * Generate the universe U of possible values that can result from the - * feature's rules' requirements. - * - * This generates an object of this form: - * var universe = { - * a: { - * 'touchedByAllowedPropertyRule': false, - * 'tag': false, - * 'attributes:href': false, - * 'classes:external': false, - * }, - * strong: { - * 'touchedByAllowedPropertyRule': false, - * 'tag': false, - * }, - * img: { - * 'touchedByAllowedPropertyRule': false, - * 'tag': false, - * 'attributes:src': false - * } - * }; - * - * In this example, the given text editor feature resulted in the above - * universe, which shows that it must be allowed to generate the a, - * strong and img tags. For the a tag, it must be able to set the "href" - * attribute and the "external" class. For the strong tag, no further - * properties are required. For the img tag, the "src" attribute is - * required. The "tag" key is used to track whether that tag was - * explicitly allowed by one of the filter's rules. The - * "touchedByAllowedPropertyRule" key is used for state tracking that is - * essential for filterStatusAllowsFeature() to be able to reason: when - * all of a filter's rules have been applied, and none of the forbidden - * rules matched (which would have resulted in early termination) yet the - * universe has not been made empty (which would be the end result if - * everything in the universe were explicitly allowed), then this piece - * of state data enables us to determine whether a tag whose properties - * were not all explicitly allowed are in fact still allowed, because its - * tag was explicitly allowed and there were no filter rules applying - * "allowed tag property value" restrictions for this particular tag. - * - * @param {object} feature - * The feature in question. - * - * @return {object} - * The universe generated. - * - * @see findPropertyValueOnTag() - * @see filterStatusAllowsFeature() - */ + featureIsAllowedByFilters: function featureIsAllowedByFilters(feature) { function generateUniverseFromFeatureRequirements(feature) { var properties = ['attributes', 'styles', 'classes']; var universe = {}; @@ -149,31 +31,21 @@ for (var r = 0; r < feature.rules.length; r++) { var featureRule = feature.rules[r]; - // For each tag required by this feature rule, create a basic entry in - // the universe. var requiredTags = featureRule.required.tags; for (var t = 0; t < requiredTags.length; t++) { universe[requiredTags[t]] = { - // Whether this tag was allowed or not. tag: false, - // Whether any filter rule that applies to this tag had an allowed - // property rule. i.e. will become true if >=1 filter rule has >=1 - // allowed property rule. + touchedByAllowedPropertyRule: false, - // Analogous, but for forbidden property rule. + touchedBytouchedByForbiddenPropertyRule: false }; } - // If no required properties are defined for this rule, we can move on - // to the next feature. if (emptyProperties(featureRule.required)) { continue; } - // Expand the existing universe, assume that each tags' property - // value is disallowed. If the filter rules allow everything in the - // feature's universe, then the feature is allowed. for (var p = 0; p < properties.length; p++) { var property = properties[p]; for (var pv = 0; pv < featureRule.required[property].length; pv++) { @@ -186,41 +58,11 @@ return universe; } - /** - * Provided a section of a feature or filter rule, checks if no property - * values are defined for all properties: attributes, classes and styles. - * - * @param {object} section - * The section to check. - * - * @return {bool} - * Returns true if the section has empty properties, false otherwise. - */ function emptyProperties(section) { return section.attributes.length === 0 && section.classes.length === 0 && section.styles.length === 0; } - /** - * Calls findPropertyValueOnTag on the given tag for every property value - * that is listed in the "propertyValues" parameter. Supports the wildcard - * tag. - * - * @param {object} universe - * The universe to check. - * @param {string} tag - * The tag to look for. - * @param {string} property - * The property to check. - * @param {Array} propertyValues - * Values of the property to check. - * @param {bool} allowing - * Whether to update the universe or not. - * - * @return {bool} - * Returns true if found, false otherwise. - */ function findPropertyValuesOnTag(universe, tag, property, propertyValues, allowing) { - // Detect the wildcard case. if (tag === '*') { return findPropertyValuesOnAllTags(universe, property, propertyValues, allowing); } @@ -234,21 +76,6 @@ return atLeastOneFound; } - /** - * Calls findPropertyValuesOnAllTags for all tags in the universe. - * - * @param {object} universe - * The universe to check. - * @param {string} property - * The property to check. - * @param {Array} propertyValues - * Values of the property to check. - * @param {bool} allowing - * Whether to update the universe or not. - * - * @return {bool} - * Returns true if found, false otherwise. - */ function findPropertyValuesOnAllTags(universe, property, propertyValues, allowing) { var atLeastOneFound = false; _.each(_.keys(universe), function (tag) { @@ -259,43 +86,17 @@ return atLeastOneFound; } - /** - * Finds out if a specific property value (potentially containing - * wildcards) exists on the given tag. When the "allowing" parameter - * equals true, the universe will be updated if that specific property - * value exists. Returns true if found, false otherwise. - * - * @param {object} universe - * The universe to check. - * @param {string} tag - * The tag to look for. - * @param {string} property - * The property to check. - * @param {string} propertyValue - * The property value to check. - * @param {bool} allowing - * Whether to update the universe or not. - * - * @return {bool} - * Returns true if found, false otherwise. - */ function findPropertyValueOnTag(universe, tag, property, propertyValue, allowing) { - // If the tag does not exist in the universe, then it definitely can't - // have this specific property value. if (!_.has(universe, tag)) { return false; } var key = property + ':' + propertyValue; - // Track whether a tag was touched by a filter rule that allows specific - // property values on this particular tag. - // @see generateUniverseFromFeatureRequirements if (allowing) { universe[tag].touchedByAllowedPropertyRule = true; } - // The simple case: no wildcard in property value. if (_.indexOf(propertyValue, '*') === -1) { if (_.has(universe, tag) && _.has(universe[tag], key)) { if (allowing) { @@ -304,37 +105,22 @@ return true; } return false; - } - // The complex case: wildcard in property value. - else { - var atLeastOneFound = false; - var regex = key.replace(/\*/g, '[^ ]*'); - _.each(_.keys(universe[tag]), function (key) { - if (key.match(regex)) { - atLeastOneFound = true; - if (allowing) { - universe[tag][key] = true; + } else { + var atLeastOneFound = false; + var regex = key.replace(/\*/g, '[^ ]*'); + _.each(_.keys(universe[tag]), function (key) { + if (key.match(regex)) { + atLeastOneFound = true; + if (allowing) { + universe[tag][key] = true; + } } - } - }); - return atLeastOneFound; - } + }); + return atLeastOneFound; + } } - /** - * Deletes a tag from the universe if the tag itself and each of its - * properties are marked as allowed. - * - * @param {object} universe - * The universe to delete from. - * @param {string} tag - * The tag to check. - * - * @return {bool} - * Whether something was deleted from the universe. - */ function deleteFromUniverseIfAllowed(universe, tag) { - // Detect the wildcard case. if (tag === '*') { return deleteAllTagsFromUniverseIfAllowed(universe); } @@ -345,15 +131,6 @@ return false; } - /** - * Calls deleteFromUniverseIfAllowed for all tags in the universe. - * - * @param {object} universe - * The universe to delete from. - * - * @return {bool} - * Whether something was deleted from the universe. - */ function deleteAllTagsFromUniverseIfAllowed(universe) { var atLeastOneDeleted = false; _.each(_.keys(universe), function (tag) { @@ -364,22 +141,9 @@ return atLeastOneDeleted; } - /** - * Checks if any filter rule forbids either a tag or a tag property value - * that exists in the universe. - * - * @param {object} universe - * Universe to check. - * @param {object} filterStatus - * Filter status to use for check. - * - * @return {bool} - * Whether any filter rule forbids something in the universe. - */ function anyForbiddenFilterRuleMatches(universe, filterStatus) { var properties = ['attributes', 'styles', 'classes']; - // Check if a tag in the universe is forbidden. var allRequiredTags = _.keys(universe); var filterRule; for (var i = 0; i < filterStatus.rules.length; i++) { @@ -391,20 +155,16 @@ } } - // Check if a property value of a tag in the universe is forbidden. - // For all filter rules… for (var n = 0; n < filterStatus.rules.length; n++) { filterRule = filterStatus.rules[n]; - // … if there are tags with restricted property values … + if (filterRule.restrictedTags.tags.length && !emptyProperties(filterRule.restrictedTags.forbidden)) { - // … for all those tags … for (var j = 0; j < filterRule.restrictedTags.tags.length; j++) { var tag = filterRule.restrictedTags.tags[j]; - // … then iterate over all properties … + for (var k = 0; k < properties.length; k++) { var property = properties[k]; - // … and return true if just one of the forbidden property - // values for this tag and property is listed in the universe. + if (findPropertyValuesOnTag(universe, tag, property, filterRule.restrictedTags.forbidden[property], false)) { return true; } @@ -416,21 +176,9 @@ return false; } - /** - * Applies every filter rule's explicit allowing of a tag or a tag - * property value to the universe. Whenever both the tag and all of its - * required property values are marked as explicitly allowed, they are - * deleted from the universe. - * - * @param {object} universe - * Universe to delete from. - * @param {object} filterStatus - * The filter status in question. - */ function markAllowedTagsAndPropertyValues(universe, filterStatus) { var properties = ['attributes', 'styles', 'classes']; - // Check if a tag in the universe is allowed. var filterRule; var tag; for (var l = 0; !_.isEmpty(universe) && l < filterStatus.rules.length; l++) { @@ -446,22 +194,16 @@ } } - // Check if a property value of a tag in the universe is allowed. - // For all filter rules… for (var i = 0; !_.isEmpty(universe) && i < filterStatus.rules.length; i++) { filterRule = filterStatus.rules[i]; - // … if there are tags with restricted property values … + if (filterRule.restrictedTags.tags.length && !emptyProperties(filterRule.restrictedTags.allowed)) { - // … for all those tags … for (var j = 0; !_.isEmpty(universe) && j < filterRule.restrictedTags.tags.length; j++) { tag = filterRule.restrictedTags.tags[j]; - // … then iterate over all properties … + for (var k = 0; k < properties.length; k++) { var property = properties[k]; - // … and try to delete this tag from the universe if just one - // of the allowed property values for this tag and property is - // listed in the universe. (Because everything might be allowed - // now.) + if (findPropertyValuesOnTag(universe, tag, property, filterRule.restrictedTags.allowed[property], true)) { deleteFromUniverseIfAllowed(universe, tag); } @@ -471,114 +213,57 @@ } } - /** - * Checks whether the current status of a filter allows a specific feature - * by building the universe of potential values from the feature's - * requirements and then checking whether anything in the filter prevents - * that. - * - * @param {object} filterStatus - * The filter status in question. - * @param {object} feature - * The feature requested. - * - * @return {bool} - * Whether the current status of the filter allows specified feature. - * - * @see generateUniverseFromFeatureRequirements() - */ function filterStatusAllowsFeature(filterStatus, feature) { - // An inactive filter by definition allows the feature. if (!filterStatus.active) { return true; } - // A feature that specifies no rules has no HTML requirements and is - // hence allowed by definition. if (feature.rules.length === 0) { return true; } - // Analogously for a filter that specifies no rules. if (filterStatus.rules.length === 0) { return true; } - // Generate the universe U of possible values that can result from the - // feature's rules' requirements. var universe = generateUniverseFromFeatureRequirements(feature); - // If anything that is in the universe (and is thus required by the - // feature) is forbidden by any of the filter's rules, then this filter - // does not allow this feature. if (anyForbiddenFilterRuleMatches(universe, filterStatus)) { return false; } - // Mark anything in the universe that is allowed by any of the filter's - // rules as allowed. If everything is explicitly allowed, then the - // universe will become empty. markAllowedTagsAndPropertyValues(universe, filterStatus); - // If there was at least one filter rule allowing tags, then everything - // in the universe must be allowed for this feature to be allowed, and - // thus by now it must be empty. However, it is still possible that the - // filter allows the feature, due to no rules for allowing tag property - // values and/or rules for forbidding tag property values. For details: - // see the comments below. - // @see generateUniverseFromFeatureRequirements() if (_.some(_.pluck(filterStatus.rules, 'allow'))) { - // If the universe is empty, then everything was explicitly allowed - // and our job is done: this filter allows this feature! if (_.isEmpty(universe)) { return true; - } - // Otherwise, it is still possible that this feature is allowed. - else { - // Every tag must be explicitly allowed if there are filter rules - // doing tag whitelisting. - if (!_.every(_.pluck(universe, 'tag'))) { - return false; - } - // Every tag was explicitly allowed, but since the universe is not - // empty, one or more tag properties are disallowed. However, if - // only blacklisting of tag properties was applied to these tags, - // and no whitelisting was ever applied, then it's still fine: - // since none of the tag properties were blacklisted, we got to - // this point, and since no whitelisting was applied, it doesn't - // matter that the properties: this could never have happened - // anyway. It's only this late that we can know this for certain. - else { - var tags = _.keys(universe); - // Figure out if there was any rule applying whitelisting tag - // restrictions to each of the remaining tags. - for (var i = 0; i < tags.length; i++) { - var tag = tags[i]; - if (_.has(universe, tag)) { - if (universe[tag].touchedByAllowedPropertyRule === false) { - delete universe[tag]; + } else { + if (!_.every(_.pluck(universe, 'tag'))) { + return false; + } else { + var tags = _.keys(universe); + + for (var i = 0; i < tags.length; i++) { + var tag = tags[i]; + if (_.has(universe, tag)) { + if (universe[tag].touchedByAllowedPropertyRule === false) { + delete universe[tag]; + } + } } + return _.isEmpty(universe); } - } - return _.isEmpty(universe); } + } else { + return true; } - } - // Otherwise, if all filter rules were doing blacklisting, then the sole - // fact that we got to this point indicates that this filter allows for - // everything that is required for this feature. - else { - return true; - } } - // If any filter's current status forbids the editor feature, return - // false. Drupal.filterConfiguration.update(); for (var filterID in Drupal.filterConfiguration.statuses) { if (Drupal.filterConfiguration.statuses.hasOwnProperty(filterID)) { var filterStatus = Drupal.filterConfiguration.statuses[filterID]; - if (!(filterStatusAllowsFeature(filterStatus, feature))) { + if (!filterStatusAllowsFeature(filterStatus, feature)) { return false; } } @@ -588,248 +273,43 @@ } }; - /** - * Constructor for an editor feature HTML rule. - * - * Intended to be used in combination with {@link Drupal.EditorFeature}. - * - * A text editor feature rule object describes both: - * - required HTML tags, attributes, styles and classes: without these, the - * text editor feature is unable to function. It's possible that a - * - allowed HTML tags, attributes, styles and classes: these are optional - * in the strictest sense, but it is possible that the feature generates - * them. - * - * The structure can be very clearly seen below: there's a "required" and an - * "allowed" key. For each of those, there are objects with the "tags", - * "attributes", "styles" and "classes" keys. For all these keys the values - * are initialized to the empty array. List each possible value as an array - * value. Besides the "required" and "allowed" keys, there's an optional - * "raw" key: it allows text editor implementations to optionally pass in - * their raw representation instead of the Drupal-defined representation for - * HTML rules. - * - * @example - * tags: ['<a>'] - * attributes: ['href', 'alt'] - * styles: ['color', 'text-decoration'] - * classes: ['external', 'internal'] - * - * @constructor - * - * @see Drupal.EditorFeature - */ Drupal.EditorFeatureHTMLRule = function () { + this.required = { tags: [], attributes: [], styles: [], classes: [] }; + + this.allowed = { tags: [], attributes: [], styles: [], classes: [] }; - /** - * - * @type {object} - * - * @prop {Array} tags - * @prop {Array} attributes - * @prop {Array} styles - * @prop {Array} classes - */ - this.required = {tags: [], attributes: [], styles: [], classes: []}; - - /** - * - * @type {object} - * - * @prop {Array} tags - * @prop {Array} attributes - * @prop {Array} styles - * @prop {Array} classes - */ - this.allowed = {tags: [], attributes: [], styles: [], classes: []}; - - /** - * - * @type {null} - */ this.raw = null; }; - /** - * A text editor feature object. Initialized with the feature name. - * - * Contains a set of HTML rules ({@link Drupal.EditorFeatureHTMLRule} objects) - * that describe which HTML tags, attributes, styles and classes are required - * (i.e. essential for the feature to function at all) and which are allowed - * (i.e. the feature may generate this, but they're not essential). - * - * It is necessary to allow for multiple HTML rules per feature: with just - * one HTML rule per feature, there is not enough expressiveness to describe - * certain cases. For example: a "table" feature would probably require the - * `<table>` tag, and might allow e.g. the "summary" attribute on that tag. - * However, the table feature would also require the `<tr>` and `<td>` tags, - * but it doesn't make sense to allow for a "summary" attribute on these tags. - * Hence these would need to be split in two separate rules. - * - * HTML rules must be added with the `addHTMLRule()` method. A feature that - * has zero HTML rules does not create or modify HTML. - * - * @constructor - * - * @param {string} name - * The name of the feature. - * - * @see Drupal.EditorFeatureHTMLRule - */ Drupal.EditorFeature = function (name) { this.name = name; this.rules = []; }; - /** - * Adds a HTML rule to the list of HTML rules for this feature. - * - * @param {Drupal.EditorFeatureHTMLRule} rule - * A text editor feature HTML rule. - */ Drupal.EditorFeature.prototype.addHTMLRule = function (rule) { this.rules.push(rule); }; - /** - * Text filter status object. Initialized with the filter ID. - * - * Indicates whether the text filter is currently active (enabled) or not. - * - * Contains a set of HTML rules ({@link Drupal.FilterHTMLRule} objects) that - * describe which HTML tags are allowed or forbidden. They can also describe - * for a set of tags (or all tags) which attributes, styles and classes are - * allowed and which are forbidden. - * - * It is necessary to allow for multiple HTML rules per feature, for - * analogous reasons as {@link Drupal.EditorFeature}. - * - * HTML rules must be added with the `addHTMLRule()` method. A filter that has - * zero HTML rules does not disallow any HTML. - * - * @constructor - * - * @param {string} name - * The name of the feature. - * - * @see Drupal.FilterHTMLRule - */ Drupal.FilterStatus = function (name) { - - /** - * - * @type {string} - */ this.name = name; - /** - * - * @type {bool} - */ this.active = false; - /** - * - * @type {Array.<Drupal.FilterHTMLRule>} - */ this.rules = []; }; - /** - * Adds a HTML rule to the list of HTML rules for this filter. - * - * @param {Drupal.FilterHTMLRule} rule - * A text filter HTML rule. - */ Drupal.FilterStatus.prototype.addHTMLRule = function (rule) { this.rules.push(rule); }; - /** - * A text filter HTML rule object. - * - * Intended to be used in combination with {@link Drupal.FilterStatus}. - * - * A text filter rule object describes: - * 1. allowed or forbidden tags: (optional) whitelist or blacklist HTML tags - * 2. restricted tag properties: (optional) whitelist or blacklist - * attributes, styles and classes on a set of HTML tags. - * - * Typically, each text filter rule object does either 1 or 2, not both. - * - * The structure can be very clearly seen below: - * 1. use the "tags" key to list HTML tags, and set the "allow" key to - * either true (to allow these HTML tags) or false (to forbid these HTML - * tags). If you leave the "tags" key's default value (the empty array), - * no restrictions are applied. - * 2. all nested within the "restrictedTags" key: use the "tags" subkey to - * list HTML tags to which you want to apply property restrictions, then - * use the "allowed" subkey to whitelist specific property values, and - * similarly use the "forbidden" subkey to blacklist specific property - * values. - * - * @example - * <caption>Whitelist the "p", "strong" and "a" HTML tags.</caption> - * { - * tags: ['p', 'strong', 'a'], - * allow: true, - * restrictedTags: { - * tags: [], - * allowed: { attributes: [], styles: [], classes: [] }, - * forbidden: { attributes: [], styles: [], classes: [] } - * } - * } - * @example - * <caption>For the "a" HTML tag, only allow the "href" attribute - * and the "external" class and disallow the "target" attribute.</caption> - * { - * tags: [], - * allow: null, - * restrictedTags: { - * tags: ['a'], - * allowed: { attributes: ['href'], styles: [], classes: ['external'] }, - * forbidden: { attributes: ['target'], styles: [], classes: [] } - * } - * } - * @example - * <caption>For all tags, allow the "data-*" attribute (that is, any - * attribute that begins with "data-").</caption> - * { - * tags: [], - * allow: null, - * restrictedTags: { - * tags: ['*'], - * allowed: { attributes: ['data-*'], styles: [], classes: [] }, - * forbidden: { attributes: [], styles: [], classes: [] } - * } - * } - * - * @return {object} - * An object with the following structure: - * ``` - * { - * tags: Array, - * allow: null, - * restrictedTags: { - * tags: Array, - * allowed: {attributes: Array, styles: Array, classes: Array}, - * forbidden: {attributes: Array, styles: Array, classes: Array} - * } - * } - * ``` - * - * @see Drupal.FilterStatus - */ Drupal.FilterHTMLRule = function () { - // Allow or forbid tags. this.tags = []; this.allow = null; - // Apply restrictions to properties set on tags. this.restrictedTags = { tags: [], - allowed: {attributes: [], styles: [], classes: []}, - forbidden: {attributes: [], styles: [], classes: []} + allowed: { attributes: [], styles: [], classes: [] }, + forbidden: { attributes: [], styles: [], classes: [] } }; return this; @@ -849,52 +329,16 @@ return clone; }; - /** - * Tracks the configuration of all text filters in {@link Drupal.FilterStatus} - * objects for {@link Drupal.editorConfiguration.featureIsAllowedByFilters}. - * - * @namespace - */ Drupal.filterConfiguration = { - - /** - * Drupal.FilterStatus objects, keyed by filter ID. - * - * @type {Object.<string, Drupal.FilterStatus>} - */ statuses: {}, - /** - * Live filter setting parsers. - * - * Object keyed by filter ID, for those filters that implement it. - * - * Filters should load the implementing JavaScript on the filter - * configuration form and implement - * `Drupal.filterSettings[filterID].getRules()`, which should return an - * array of {@link Drupal.FilterHTMLRule} objects. - * - * @namespace - */ liveSettingParsers: {}, - /** - * Updates all {@link Drupal.FilterStatus} objects to reflect current state. - * - * Automatically checks whether a filter is currently enabled or not. To - * support more finegrained. - * - * If a filter implements a live setting parser, then that will be used to - * keep the HTML rules for the {@link Drupal.FilterStatus} object - * up-to-date. - */ - update: function () { + update: function update() { for (var filterID in Drupal.filterConfiguration.statuses) { if (Drupal.filterConfiguration.statuses.hasOwnProperty(filterID)) { - // Update status. Drupal.filterConfiguration.statuses[filterID].active = $('[name="filters[' + filterID + '][status]"]').is(':checked'); - // Update current rules. if (Drupal.filterConfiguration.liveSettingParsers[filterID]) { Drupal.filterConfiguration.statuses[filterID].rules = Drupal.filterConfiguration.liveSettingParsers[filterID].getRules(); } @@ -904,32 +348,18 @@ }; - /** - * Initializes {@link Drupal.filterConfiguration}. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Gets filter configuration from filter form input. - */ Drupal.behaviors.initializeFilterConfiguration = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $context = $(context); $context.find('#filters-status-wrapper input.form-checkbox').once('filter-editor-status').each(function () { var $checkbox = $(this); var nameAttribute = $checkbox.attr('name'); - // The filter's checkbox has a name attribute of the form - // "filters[<name of filter>][status]", parse "<name of filter>" - // from it. var filterID = nameAttribute.substring(8, nameAttribute.indexOf(']')); - // Create a Drupal.FilterStatus object to track the state (whether it's - // active or not and its current settings, if any) of each filter. Drupal.filterConfiguration.statuses[filterID] = new Drupal.FilterStatus(filterID); }); } }; - -})(jQuery, _, Drupal, document); +})(jQuery, _, Drupal, document); \ No newline at end of file diff --git a/core/modules/editor/js/editor.dialog.es6.js b/core/modules/editor/js/editor.dialog.es6.js new file mode 100644 index 000000000000..3bf6120c7af6 --- /dev/null +++ b/core/modules/editor/js/editor.dialog.es6.js @@ -0,0 +1,34 @@ +/** + * @file + * AJAX commands used by Editor module. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Command to save the contents of an editor-provided modal. + * + * This command does not close the open modal. It should be followed by a + * call to `Drupal.AjaxCommands.prototype.closeDialog`. Editors that are + * integrated with dialogs must independently listen for an + * `editor:dialogsave` event to save the changes into the contents of their + * interface. + * + * @param {Drupal.Ajax} [ajax] + * The Drupal.Ajax object. + * @param {object} response + * The server response from the ajax request. + * @param {Array} response.values + * The values that were saved. + * @param {number} [status] + * The status code from the ajax request. + * + * @fires event:editor:dialogsave + */ + Drupal.AjaxCommands.prototype.editorDialogSave = function (ajax, response, status) { + $(window).trigger('editor:dialogsave', [response.values]); + }; + +})(jQuery, Drupal); diff --git a/core/modules/editor/js/editor.dialog.js b/core/modules/editor/js/editor.dialog.js index 3bf6120c7af6..bba05a7665e1 100644 --- a/core/modules/editor/js/editor.dialog.js +++ b/core/modules/editor/js/editor.dialog.js @@ -1,34 +1,16 @@ /** - * @file - * AJAX commands used by Editor module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/editor/js/editor.dialog.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Command to save the contents of an editor-provided modal. - * - * This command does not close the open modal. It should be followed by a - * call to `Drupal.AjaxCommands.prototype.closeDialog`. Editors that are - * integrated with dialogs must independently listen for an - * `editor:dialogsave` event to save the changes into the contents of their - * interface. - * - * @param {Drupal.Ajax} [ajax] - * The Drupal.Ajax object. - * @param {object} response - * The server response from the ajax request. - * @param {Array} response.values - * The values that were saved. - * @param {number} [status] - * The status code from the ajax request. - * - * @fires event:editor:dialogsave - */ Drupal.AjaxCommands.prototype.editorDialogSave = function (ajax, response, status) { $(window).trigger('editor:dialogsave', [response.values]); }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/editor/js/editor.es6.js b/core/modules/editor/js/editor.es6.js new file mode 100644 index 000000000000..2be16f57c5d1 --- /dev/null +++ b/core/modules/editor/js/editor.es6.js @@ -0,0 +1,318 @@ +/** + * @file + * Attaches behavior for the Editor module. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Finds the text area field associated with the given text format selector. + * + * @param {jQuery} $formatSelector + * A text format selector DOM element. + * + * @return {HTMLElement} + * The text area DOM element, if it was found. + */ + function findFieldForFormatSelector($formatSelector) { + var field_id = $formatSelector.attr('data-editor-for'); + // This selector will only find text areas in the top-level document. We do + // not support attaching editors on text areas within iframes. + return $('#' + field_id).get(0); + } + + /** + * Changes the text editor on a text area. + * + * @param {HTMLElement} field + * The text area DOM element. + * @param {string} newFormatID + * The text format we're changing to; the text editor for the currently + * active text format will be detached, and the text editor for the new text + * format will be attached. + */ + function changeTextEditor(field, newFormatID) { + var previousFormatID = field.getAttribute('data-editor-active-text-format'); + + // Detach the current editor (if any) and attach a new editor. + if (drupalSettings.editor.formats[previousFormatID]) { + Drupal.editorDetach(field, drupalSettings.editor.formats[previousFormatID]); + } + // When no text editor is currently active, stop tracking changes. + else { + $(field).off('.editor'); + } + + // Attach the new text editor (if any). + if (drupalSettings.editor.formats[newFormatID]) { + var format = drupalSettings.editor.formats[newFormatID]; + filterXssWhenSwitching(field, format, previousFormatID, Drupal.editorAttach); + } + + // Store the new active format. + field.setAttribute('data-editor-active-text-format', newFormatID); + } + + /** + * Handles changes in text format. + * + * @param {jQuery.Event} event + * The text format change event. + */ + function onTextFormatChange(event) { + var $select = $(event.target); + var field = event.data.field; + var activeFormatID = field.getAttribute('data-editor-active-text-format'); + var newFormatID = $select.val(); + + // Prevent double-attaching if the change event is triggered manually. + if (newFormatID === activeFormatID) { + return; + } + + // When changing to a text format that has a text editor associated + // with it that supports content filtering, then first ask for + // confirmation, because switching text formats might cause certain + // markup to be stripped away. + var supportContentFiltering = drupalSettings.editor.formats[newFormatID] && drupalSettings.editor.formats[newFormatID].editorSupportsContentFiltering; + // If there is no content yet, it's always safe to change the text format. + var hasContent = field.value !== ''; + if (hasContent && supportContentFiltering) { + var message = Drupal.t('Changing the text format to %text_format will permanently remove content that is not allowed in that text format.<br><br>Save your changes before switching the text format to avoid losing data.', { + '%text_format': $select.find('option:selected').text() + }); + var confirmationDialog = Drupal.dialog('<div>' + message + '</div>', { + title: Drupal.t('Change text format?'), + dialogClass: 'editor-change-text-format-modal', + resizable: false, + buttons: [ + { + text: Drupal.t('Continue'), + class: 'button button--primary', + click: function () { + changeTextEditor(field, newFormatID); + confirmationDialog.close(); + } + }, + { + text: Drupal.t('Cancel'), + class: 'button', + click: function () { + // Restore the active format ID: cancel changing text format. We + // cannot simply call event.preventDefault() because jQuery's + // change event is only triggered after the change has already + // been accepted. + $select.val(activeFormatID); + confirmationDialog.close(); + } + } + ], + // Prevent this modal from being closed without the user making a choice + // as per http://stackoverflow.com/a/5438771. + closeOnEscape: false, + create: function () { + $(this).parent().find('.ui-dialog-titlebar-close').remove(); + }, + beforeClose: false, + close: function (event) { + // Automatically destroy the DOM element that was used for the dialog. + $(event.target).remove(); + } + }); + + confirmationDialog.showModal(); + } + else { + changeTextEditor(field, newFormatID); + } + } + + /** + * Initialize an empty object for editors to place their attachment code. + * + * @namespace + */ + Drupal.editors = {}; + + /** + * Enables editors on text_format elements. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches an editor to an input element. + * @prop {Drupal~behaviorDetach} detach + * Detaches an editor from an input element. + */ + Drupal.behaviors.editor = { + attach: function (context, settings) { + // If there are no editor settings, there are no editors to enable. + if (!settings.editor) { + return; + } + + $(context).find('[data-editor-for]').once('editor').each(function () { + var $this = $(this); + var field = findFieldForFormatSelector($this); + + // Opt-out if no supported text area was found. + if (!field) { + return; + } + + // Store the current active format. + var activeFormatID = $this.val(); + field.setAttribute('data-editor-active-text-format', activeFormatID); + + // Directly attach this text editor, if the text format is enabled. + if (settings.editor.formats[activeFormatID]) { + // XSS protection for the current text format/editor is performed on + // the server side, so we don't need to do anything special here. + Drupal.editorAttach(field, settings.editor.formats[activeFormatID]); + } + // When there is no text editor for this text format, still track + // changes, because the user has the ability to switch to some text + // editor, otherwise this code would not be executed. + $(field).on('change.editor keypress.editor', function () { + field.setAttribute('data-editor-value-is-changed', 'true'); + // Just knowing that the value was changed is enough, stop tracking. + $(field).off('.editor'); + }); + + // Attach onChange handler to text format selector element. + if ($this.is('select')) { + $this.on('change.editorAttach', {field: field}, onTextFormatChange); + } + // Detach any editor when the containing form is submitted. + $this.parents('form').on('submit', function (event) { + // Do not detach if the event was canceled. + if (event.isDefaultPrevented()) { + return; + } + // Detach the current editor (if any). + if (settings.editor.formats[activeFormatID]) { + Drupal.editorDetach(field, settings.editor.formats[activeFormatID], 'serialize'); + } + }); + }); + }, + + detach: function (context, settings, trigger) { + var editors; + // The 'serialize' trigger indicates that we should simply update the + // underlying element with the new text, without destroying the editor. + if (trigger === 'serialize') { + // Removing the editor-processed class guarantees that the editor will + // be reattached. Only do this if we're planning to destroy the editor. + editors = $(context).find('[data-editor-for]').findOnce('editor'); + } + else { + editors = $(context).find('[data-editor-for]').removeOnce('editor'); + } + + editors.each(function () { + var $this = $(this); + var activeFormatID = $this.val(); + var field = findFieldForFormatSelector($this); + if (field && activeFormatID in settings.editor.formats) { + Drupal.editorDetach(field, settings.editor.formats[activeFormatID], trigger); + } + }); + } + }; + + /** + * Attaches editor behaviors to the field. + * + * @param {HTMLElement} field + * The textarea DOM element. + * @param {object} format + * The text format that's being activated, from + * drupalSettings.editor.formats. + * + * @listens event:change + * + * @fires event:formUpdated + */ + Drupal.editorAttach = function (field, format) { + if (format.editor) { + // Attach the text editor. + Drupal.editors[format.editor].attach(field, format); + + // Ensures form.js' 'formUpdated' event is triggered even for changes that + // happen within the text editor. + Drupal.editors[format.editor].onChange(field, function () { + $(field).trigger('formUpdated'); + + // Keep track of changes, so we know what to do when switching text + // formats and guaranteeing XSS protection. + field.setAttribute('data-editor-value-is-changed', 'true'); + }); + } + }; + + /** + * Detaches editor behaviors from the field. + * + * @param {HTMLElement} field + * The textarea DOM element. + * @param {object} format + * The text format that's being activated, from + * drupalSettings.editor.formats. + * @param {string} trigger + * Trigger value from the detach behavior. + */ + Drupal.editorDetach = function (field, format, trigger) { + if (format.editor) { + Drupal.editors[format.editor].detach(field, format, trigger); + + // Restore the original value if the user didn't make any changes yet. + if (field.getAttribute('data-editor-value-is-changed') === 'false') { + field.value = field.getAttribute('data-editor-value-original'); + } + } + }; + + /** + * Filter away XSS attack vectors when switching text formats. + * + * @param {HTMLElement} field + * The textarea DOM element. + * @param {object} format + * The text format that's being activated, from + * drupalSettings.editor.formats. + * @param {string} originalFormatID + * The text format ID of the original text format. + * @param {function} callback + * A callback to be called (with no parameters) after the field's value has + * been XSS filtered. + */ + function filterXssWhenSwitching(field, format, originalFormatID, callback) { + // A text editor that already is XSS-safe needs no additional measures. + if (format.editor.isXssSafe) { + callback(field, format); + } + // Otherwise, ensure XSS safety: let the server XSS filter this value. + else { + $.ajax({ + url: Drupal.url('editor/filter_xss/' + format.format), + type: 'POST', + data: { + value: field.value, + original_format_id: originalFormatID + }, + dataType: 'json', + success: function (xssFilteredValue) { + // If the server returns false, then no XSS filtering is needed. + if (xssFilteredValue !== false) { + field.value = xssFilteredValue; + } + callback(field, format); + } + }); + } + } + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/editor/js/editor.formattedTextEditor.es6.js b/core/modules/editor/js/editor.formattedTextEditor.es6.js new file mode 100644 index 000000000000..fec73e03a0bf --- /dev/null +++ b/core/modules/editor/js/editor.formattedTextEditor.es6.js @@ -0,0 +1,231 @@ +/** + * @file + * Text editor-based in-place editor for formatted text content in Drupal. + * + * Depends on editor.module. Works with any (WYSIWYG) editor that implements the + * editor.js API, including the optional attachInlineEditor() and onChange() + * methods. + * For example, assuming that a hypothetical editor's name was "Magical Editor" + * and its editor.js API implementation lived at Drupal.editors.magical, this + * JavaScript would use: + * - Drupal.editors.magical.attachInlineEditor() + */ + +(function ($, Drupal, drupalSettings, _) { + + 'use strict'; + + Drupal.quickedit.editors.editor = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.editor# */{ + + /** + * The text format for this field. + * + * @type {string} + */ + textFormat: null, + + /** + * Indicates whether this text format has transformations. + * + * @type {bool} + */ + textFormatHasTransformations: null, + + /** + * Stores a reference to the text editor object for this field. + * + * @type {Drupal.quickedit.EditorModel} + */ + textEditor: null, + + /** + * Stores the textual DOM element that is being in-place edited. + * + * @type {jQuery} + */ + $textElement: null, + + /** + * @constructs + * + * @augments Drupal.quickedit.EditorView + * + * @param {object} options + * Options for the editor view. + */ + initialize: function (options) { + Drupal.quickedit.EditorView.prototype.initialize.call(this, options); + + var metadata = Drupal.quickedit.metadata.get(this.fieldModel.get('fieldID'), 'custom'); + this.textFormat = drupalSettings.editor.formats[metadata.format]; + this.textFormatHasTransformations = metadata.formatHasTransformations; + this.textEditor = Drupal.editors[this.textFormat.editor]; + + // Store the actual value of this field. We'll need this to restore the + // original value when the user discards his modifications. + var $fieldItems = this.$el.find('.quickedit-field'); + if ($fieldItems.length) { + this.$textElement = $fieldItems.eq(0); + } + else { + this.$textElement = this.$el; + } + this.model.set('originalValue', this.$textElement.html()); + }, + + /** + * @inheritdoc + * + * @return {jQuery} + * The text element edited. + */ + getEditedElement: function () { + return this.$textElement; + }, + + /** + * @inheritdoc + * + * @param {object} fieldModel + * The field model. + * @param {string} state + * The current state. + */ + stateChange: function (fieldModel, state) { + var editorModel = this.model; + var from = fieldModel.previous('state'); + var to = state; + switch (to) { + case 'inactive': + break; + + case 'candidate': + // Detach the text editor when entering the 'candidate' state from one + // of the states where it could have been attached. + if (from !== 'inactive' && from !== 'highlighted') { + this.textEditor.detach(this.$textElement.get(0), this.textFormat); + } + // A field model's editor view revert() method is invoked when an + // 'active' field becomes a 'candidate' field. But, in the case of + // this in-place editor, the content will have been *replaced* if the + // text format has transformation filters. Therefore, if we stop + // in-place editing this entity, revert explicitly. + if (from === 'active' && this.textFormatHasTransformations) { + this.revert(); + } + if (from === 'invalid') { + this.removeValidationErrors(); + } + break; + + case 'highlighted': + break; + + case 'activating': + // When transformation filters have been applied to the formatted text + // of this field, then we'll need to load a re-formatted version of it + // without the transformation filters. + if (this.textFormatHasTransformations) { + var $textElement = this.$textElement; + this._getUntransformedText(function (untransformedText) { + $textElement.html(untransformedText); + fieldModel.set('state', 'active'); + }); + } + // When no transformation filters have been applied: start WYSIWYG + // editing immediately! + else { + // Defer updating the model until the current state change has + // propagated, to not trigger a nested state change event. + _.defer(function () { + fieldModel.set('state', 'active'); + }); + } + break; + + case 'active': + var textElement = this.$textElement.get(0); + var toolbarView = fieldModel.toolbarView; + this.textEditor.attachInlineEditor( + textElement, + this.textFormat, + toolbarView.getMainWysiwygToolgroupId(), + toolbarView.getFloatedWysiwygToolgroupId() + ); + // Set the state to 'changed' whenever the content has changed. + this.textEditor.onChange(textElement, function (htmlText) { + editorModel.set('currentValue', htmlText); + fieldModel.set('state', 'changed'); + }); + break; + + case 'changed': + break; + + case 'saving': + if (from === 'invalid') { + this.removeValidationErrors(); + } + this.save(); + break; + + case 'saved': + break; + + case 'invalid': + this.showValidationErrors(); + break; + } + }, + + /** + * @inheritdoc + * + * @return {object} + * The sttings for the quick edit UI. + */ + getQuickEditUISettings: function () { + return {padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: false}; + }, + + /** + * @inheritdoc + */ + revert: function () { + this.$textElement.html(this.model.get('originalValue')); + }, + + /** + * Loads untransformed text for this field. + * + * More accurately: it re-filters formatted text to exclude transformation + * filters used by the text format. + * + * @param {function} callback + * A callback function that will receive the untransformed text. + * + * @see \Drupal\editor\Ajax\GetUntransformedTextCommand + */ + _getUntransformedText: function (callback) { + var fieldID = this.fieldModel.get('fieldID'); + + // Create a Drupal.ajax instance to load the form. + var textLoaderAjax = Drupal.ajax({ + url: Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('editor/!entity_type/!id/!field_name/!langcode/!view_mode')), + submit: {nocssjs: true} + }); + + // Implement a scoped editorGetUntransformedText AJAX command: calls the + // callback. + textLoaderAjax.commands.editorGetUntransformedText = function (ajax, response, status) { + callback(response.data); + }; + + // This will ensure our scoped editorGetUntransformedText AJAX command + // gets called. + textLoaderAjax.execute(); + } + + }); + +})(jQuery, Drupal, drupalSettings, _); diff --git a/core/modules/editor/js/editor.formattedTextEditor.js b/core/modules/editor/js/editor.formattedTextEditor.js index fec73e03a0bf..470a3c60c79f 100644 --- a/core/modules/editor/js/editor.formattedTextEditor.js +++ b/core/modules/editor/js/editor.formattedTextEditor.js @@ -1,59 +1,25 @@ /** - * @file - * Text editor-based in-place editor for formatted text content in Drupal. - * - * Depends on editor.module. Works with any (WYSIWYG) editor that implements the - * editor.js API, including the optional attachInlineEditor() and onChange() - * methods. - * For example, assuming that a hypothetical editor's name was "Magical Editor" - * and its editor.js API implementation lived at Drupal.editors.magical, this - * JavaScript would use: - * - Drupal.editors.magical.attachInlineEditor() - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/editor/js/editor.formattedTextEditor.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings, _) { 'use strict'; - Drupal.quickedit.editors.editor = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.editor# */{ - - /** - * The text format for this field. - * - * @type {string} - */ + Drupal.quickedit.editors.editor = Drupal.quickedit.EditorView.extend({ textFormat: null, - /** - * Indicates whether this text format has transformations. - * - * @type {bool} - */ textFormatHasTransformations: null, - /** - * Stores a reference to the text editor object for this field. - * - * @type {Drupal.quickedit.EditorModel} - */ textEditor: null, - /** - * Stores the textual DOM element that is being in-place edited. - * - * @type {jQuery} - */ $textElement: null, - /** - * @constructs - * - * @augments Drupal.quickedit.EditorView - * - * @param {object} options - * Options for the editor view. - */ - initialize: function (options) { + initialize: function initialize(options) { Drupal.quickedit.EditorView.prototype.initialize.call(this, options); var metadata = Drupal.quickedit.metadata.get(this.fieldModel.get('fieldID'), 'custom'); @@ -61,37 +27,20 @@ this.textFormatHasTransformations = metadata.formatHasTransformations; this.textEditor = Drupal.editors[this.textFormat.editor]; - // Store the actual value of this field. We'll need this to restore the - // original value when the user discards his modifications. var $fieldItems = this.$el.find('.quickedit-field'); if ($fieldItems.length) { this.$textElement = $fieldItems.eq(0); - } - else { + } else { this.$textElement = this.$el; } this.model.set('originalValue', this.$textElement.html()); }, - /** - * @inheritdoc - * - * @return {jQuery} - * The text element edited. - */ - getEditedElement: function () { + getEditedElement: function getEditedElement() { return this.$textElement; }, - /** - * @inheritdoc - * - * @param {object} fieldModel - * The field model. - * @param {string} state - * The current state. - */ - stateChange: function (fieldModel, state) { + stateChange: function stateChange(fieldModel, state) { var editorModel = this.model; var from = fieldModel.previous('state'); var to = state; @@ -100,16 +49,10 @@ break; case 'candidate': - // Detach the text editor when entering the 'candidate' state from one - // of the states where it could have been attached. if (from !== 'inactive' && from !== 'highlighted') { this.textEditor.detach(this.$textElement.get(0), this.textFormat); } - // A field model's editor view revert() method is invoked when an - // 'active' field becomes a 'candidate' field. But, in the case of - // this in-place editor, the content will have been *replaced* if the - // text format has transformation filters. Therefore, if we stop - // in-place editing this entity, revert explicitly. + if (from === 'active' && this.textFormatHasTransformations) { this.revert(); } @@ -122,37 +65,24 @@ break; case 'activating': - // When transformation filters have been applied to the formatted text - // of this field, then we'll need to load a re-formatted version of it - // without the transformation filters. if (this.textFormatHasTransformations) { var $textElement = this.$textElement; this._getUntransformedText(function (untransformedText) { $textElement.html(untransformedText); fieldModel.set('state', 'active'); }); - } - // When no transformation filters have been applied: start WYSIWYG - // editing immediately! - else { - // Defer updating the model until the current state change has - // propagated, to not trigger a nested state change event. - _.defer(function () { - fieldModel.set('state', 'active'); - }); - } + } else { + _.defer(function () { + fieldModel.set('state', 'active'); + }); + } break; case 'active': var textElement = this.$textElement.get(0); var toolbarView = fieldModel.toolbarView; - this.textEditor.attachInlineEditor( - textElement, - this.textFormat, - toolbarView.getMainWysiwygToolgroupId(), - toolbarView.getFloatedWysiwygToolgroupId() - ); - // Set the state to 'changed' whenever the content has changed. + this.textEditor.attachInlineEditor(textElement, this.textFormat, toolbarView.getMainWysiwygToolgroupId(), toolbarView.getFloatedWysiwygToolgroupId()); + this.textEditor.onChange(textElement, function (htmlText) { editorModel.set('currentValue', htmlText); fieldModel.set('state', 'changed'); @@ -178,54 +108,28 @@ } }, - /** - * @inheritdoc - * - * @return {object} - * The sttings for the quick edit UI. - */ - getQuickEditUISettings: function () { - return {padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: false}; + getQuickEditUISettings: function getQuickEditUISettings() { + return { padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: false }; }, - /** - * @inheritdoc - */ - revert: function () { + revert: function revert() { this.$textElement.html(this.model.get('originalValue')); }, - /** - * Loads untransformed text for this field. - * - * More accurately: it re-filters formatted text to exclude transformation - * filters used by the text format. - * - * @param {function} callback - * A callback function that will receive the untransformed text. - * - * @see \Drupal\editor\Ajax\GetUntransformedTextCommand - */ - _getUntransformedText: function (callback) { + _getUntransformedText: function _getUntransformedText(callback) { var fieldID = this.fieldModel.get('fieldID'); - // Create a Drupal.ajax instance to load the form. var textLoaderAjax = Drupal.ajax({ url: Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('editor/!entity_type/!id/!field_name/!langcode/!view_mode')), - submit: {nocssjs: true} + submit: { nocssjs: true } }); - // Implement a scoped editorGetUntransformedText AJAX command: calls the - // callback. textLoaderAjax.commands.editorGetUntransformedText = function (ajax, response, status) { callback(response.data); }; - // This will ensure our scoped editorGetUntransformedText AJAX command - // gets called. textLoaderAjax.execute(); } }); - -})(jQuery, Drupal, drupalSettings, _); +})(jQuery, Drupal, drupalSettings, _); \ No newline at end of file diff --git a/core/modules/editor/js/editor.js b/core/modules/editor/js/editor.js index 2be16f57c5d1..2c0fc15735d1 100644 --- a/core/modules/editor/js/editor.js +++ b/core/modules/editor/js/editor.js @@ -1,83 +1,50 @@ /** - * @file - * Attaches behavior for the Editor module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/editor/js/editor.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Finds the text area field associated with the given text format selector. - * - * @param {jQuery} $formatSelector - * A text format selector DOM element. - * - * @return {HTMLElement} - * The text area DOM element, if it was found. - */ function findFieldForFormatSelector($formatSelector) { var field_id = $formatSelector.attr('data-editor-for'); - // This selector will only find text areas in the top-level document. We do - // not support attaching editors on text areas within iframes. + return $('#' + field_id).get(0); } - /** - * Changes the text editor on a text area. - * - * @param {HTMLElement} field - * The text area DOM element. - * @param {string} newFormatID - * The text format we're changing to; the text editor for the currently - * active text format will be detached, and the text editor for the new text - * format will be attached. - */ function changeTextEditor(field, newFormatID) { var previousFormatID = field.getAttribute('data-editor-active-text-format'); - // Detach the current editor (if any) and attach a new editor. if (drupalSettings.editor.formats[previousFormatID]) { Drupal.editorDetach(field, drupalSettings.editor.formats[previousFormatID]); - } - // When no text editor is currently active, stop tracking changes. - else { - $(field).off('.editor'); - } + } else { + $(field).off('.editor'); + } - // Attach the new text editor (if any). if (drupalSettings.editor.formats[newFormatID]) { var format = drupalSettings.editor.formats[newFormatID]; filterXssWhenSwitching(field, format, previousFormatID, Drupal.editorAttach); } - // Store the new active format. field.setAttribute('data-editor-active-text-format', newFormatID); } - /** - * Handles changes in text format. - * - * @param {jQuery.Event} event - * The text format change event. - */ function onTextFormatChange(event) { var $select = $(event.target); var field = event.data.field; var activeFormatID = field.getAttribute('data-editor-active-text-format'); var newFormatID = $select.val(); - // Prevent double-attaching if the change event is triggered manually. if (newFormatID === activeFormatID) { return; } - // When changing to a text format that has a text editor associated - // with it that supports content filtering, then first ask for - // confirmation, because switching text formats might cause certain - // markup to be stripped away. var supportContentFiltering = drupalSettings.editor.formats[newFormatID] && drupalSettings.editor.formats[newFormatID].editorSupportsContentFiltering; - // If there is no content yet, it's always safe to change the text format. + var hasContent = field.value !== ''; if (hasContent && supportContentFiltering) { var message = Drupal.t('Changing the text format to %text_format will permanently remove content that is not allowed in that text format.<br><br>Save your changes before switching the text format to avoid losing data.', { @@ -87,68 +54,42 @@ title: Drupal.t('Change text format?'), dialogClass: 'editor-change-text-format-modal', resizable: false, - buttons: [ - { - text: Drupal.t('Continue'), - class: 'button button--primary', - click: function () { - changeTextEditor(field, newFormatID); - confirmationDialog.close(); - } - }, - { - text: Drupal.t('Cancel'), - class: 'button', - click: function () { - // Restore the active format ID: cancel changing text format. We - // cannot simply call event.preventDefault() because jQuery's - // change event is only triggered after the change has already - // been accepted. - $select.val(activeFormatID); - confirmationDialog.close(); - } + buttons: [{ + text: Drupal.t('Continue'), + class: 'button button--primary', + click: function click() { + changeTextEditor(field, newFormatID); + confirmationDialog.close(); + } + }, { + text: Drupal.t('Cancel'), + class: 'button', + click: function click() { + $select.val(activeFormatID); + confirmationDialog.close(); } - ], - // Prevent this modal from being closed without the user making a choice - // as per http://stackoverflow.com/a/5438771. + }], + closeOnEscape: false, - create: function () { + create: function create() { $(this).parent().find('.ui-dialog-titlebar-close').remove(); }, beforeClose: false, - close: function (event) { - // Automatically destroy the DOM element that was used for the dialog. + close: function close(event) { $(event.target).remove(); } }); confirmationDialog.showModal(); - } - else { + } else { changeTextEditor(field, newFormatID); } } - /** - * Initialize an empty object for editors to place their attachment code. - * - * @namespace - */ Drupal.editors = {}; - /** - * Enables editors on text_format elements. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches an editor to an input element. - * @prop {Drupal~behaviorDetach} detach - * Detaches an editor from an input element. - */ Drupal.behaviors.editor = { - attach: function (context, settings) { - // If there are no editor settings, there are no editors to enable. + attach: function attach(context, settings) { if (!settings.editor) { return; } @@ -157,41 +98,32 @@ var $this = $(this); var field = findFieldForFormatSelector($this); - // Opt-out if no supported text area was found. if (!field) { return; } - // Store the current active format. var activeFormatID = $this.val(); field.setAttribute('data-editor-active-text-format', activeFormatID); - // Directly attach this text editor, if the text format is enabled. if (settings.editor.formats[activeFormatID]) { - // XSS protection for the current text format/editor is performed on - // the server side, so we don't need to do anything special here. Drupal.editorAttach(field, settings.editor.formats[activeFormatID]); } - // When there is no text editor for this text format, still track - // changes, because the user has the ability to switch to some text - // editor, otherwise this code would not be executed. + $(field).on('change.editor keypress.editor', function () { field.setAttribute('data-editor-value-is-changed', 'true'); - // Just knowing that the value was changed is enough, stop tracking. + $(field).off('.editor'); }); - // Attach onChange handler to text format selector element. if ($this.is('select')) { - $this.on('change.editorAttach', {field: field}, onTextFormatChange); + $this.on('change.editorAttach', { field: field }, onTextFormatChange); } - // Detach any editor when the containing form is submitted. + $this.parents('form').on('submit', function (event) { - // Do not detach if the event was canceled. if (event.isDefaultPrevented()) { return; } - // Detach the current editor (if any). + if (settings.editor.formats[activeFormatID]) { Drupal.editorDetach(field, settings.editor.formats[activeFormatID], 'serialize'); } @@ -199,16 +131,12 @@ }); }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { var editors; - // The 'serialize' trigger indicates that we should simply update the - // underlying element with the new text, without destroying the editor. + if (trigger === 'serialize') { - // Removing the editor-processed class guarantees that the editor will - // be reattached. Only do this if we're planning to destroy the editor. editors = $(context).find('[data-editor-for]').findOnce('editor'); - } - else { + } else { editors = $(context).find('[data-editor-for]').removeOnce('editor'); } @@ -223,96 +151,47 @@ } }; - /** - * Attaches editor behaviors to the field. - * - * @param {HTMLElement} field - * The textarea DOM element. - * @param {object} format - * The text format that's being activated, from - * drupalSettings.editor.formats. - * - * @listens event:change - * - * @fires event:formUpdated - */ Drupal.editorAttach = function (field, format) { if (format.editor) { - // Attach the text editor. Drupal.editors[format.editor].attach(field, format); - // Ensures form.js' 'formUpdated' event is triggered even for changes that - // happen within the text editor. Drupal.editors[format.editor].onChange(field, function () { $(field).trigger('formUpdated'); - // Keep track of changes, so we know what to do when switching text - // formats and guaranteeing XSS protection. field.setAttribute('data-editor-value-is-changed', 'true'); }); } }; - /** - * Detaches editor behaviors from the field. - * - * @param {HTMLElement} field - * The textarea DOM element. - * @param {object} format - * The text format that's being activated, from - * drupalSettings.editor.formats. - * @param {string} trigger - * Trigger value from the detach behavior. - */ Drupal.editorDetach = function (field, format, trigger) { if (format.editor) { Drupal.editors[format.editor].detach(field, format, trigger); - // Restore the original value if the user didn't make any changes yet. if (field.getAttribute('data-editor-value-is-changed') === 'false') { field.value = field.getAttribute('data-editor-value-original'); } } }; - /** - * Filter away XSS attack vectors when switching text formats. - * - * @param {HTMLElement} field - * The textarea DOM element. - * @param {object} format - * The text format that's being activated, from - * drupalSettings.editor.formats. - * @param {string} originalFormatID - * The text format ID of the original text format. - * @param {function} callback - * A callback to be called (with no parameters) after the field's value has - * been XSS filtered. - */ function filterXssWhenSwitching(field, format, originalFormatID, callback) { - // A text editor that already is XSS-safe needs no additional measures. if (format.editor.isXssSafe) { callback(field, format); - } - // Otherwise, ensure XSS safety: let the server XSS filter this value. - else { - $.ajax({ - url: Drupal.url('editor/filter_xss/' + format.format), - type: 'POST', - data: { - value: field.value, - original_format_id: originalFormatID - }, - dataType: 'json', - success: function (xssFilteredValue) { - // If the server returns false, then no XSS filtering is needed. - if (xssFilteredValue !== false) { - field.value = xssFilteredValue; + } else { + $.ajax({ + url: Drupal.url('editor/filter_xss/' + format.format), + type: 'POST', + data: { + value: field.value, + original_format_id: originalFormatID + }, + dataType: 'json', + success: function success(xssFilteredValue) { + if (xssFilteredValue !== false) { + field.value = xssFilteredValue; + } + callback(field, format); } - callback(field, format); - } - }); - } + }); + } } - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/field_ui/field_ui.es6.js b/core/modules/field_ui/field_ui.es6.js new file mode 100644 index 000000000000..30e983a14348 --- /dev/null +++ b/core/modules/field_ui/field_ui.es6.js @@ -0,0 +1,335 @@ +/** + * @file + * Attaches the behaviors for the Field UI module. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Adds behaviors to the field storage add form. + */ + Drupal.behaviors.fieldUIFieldStorageAddForm = { + attach: function (context) { + var $form = $(context).find('[data-drupal-selector="field-ui-field-storage-add-form"]').once('field_ui_add'); + if ($form.length) { + // Add a few 'js-form-required' and 'form-required' css classes here. + // We can not use the Form API '#required' property because both label + // elements for "add new" and "re-use existing" can never be filled and + // submitted at the same time. The actual validation will happen + // server-side. + $form.find( + '.js-form-item-label label,' + + '.js-form-item-field-name label,' + + '.js-form-item-existing-storage-label label') + .addClass('js-form-required form-required'); + + var $newFieldType = $form.find('select[name="new_storage_type"]'); + var $existingStorageName = $form.find('select[name="existing_storage_name"]'); + var $existingStorageLabel = $form.find('input[name="existing_storage_label"]'); + + // When the user selects a new field type, clear the "existing field" + // selection. + $newFieldType.on('change', function () { + if ($(this).val() !== '') { + // Reset the "existing storage name" selection. + $existingStorageName.val('').trigger('change'); + } + }); + + // When the user selects an existing storage name, clear the "new field + // type" selection and populate the 'existing_storage_label' element. + $existingStorageName.on('change', function () { + var value = $(this).val(); + if (value !== '') { + // Reset the "new field type" selection. + $newFieldType.val('').trigger('change'); + + // Pre-populate the "existing storage label" element. + if (typeof drupalSettings.existingFieldLabels[value] !== 'undefined') { + $existingStorageLabel.val(drupalSettings.existingFieldLabels[value]); + } + } + }); + } + } + }; + + /** + * Attaches the fieldUIOverview behavior. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the fieldUIOverview behavior. + * + * @see Drupal.fieldUIOverview.attach + */ + Drupal.behaviors.fieldUIDisplayOverview = { + attach: function (context, settings) { + $(context).find('table#field-display-overview').once('field-display-overview').each(function () { + Drupal.fieldUIOverview.attach(this, settings.fieldUIRowsData, Drupal.fieldUIDisplayOverview); + }); + } + }; + + /** + * Namespace for the field UI overview. + * + * @namespace + */ + Drupal.fieldUIOverview = { + + /** + * Attaches the fieldUIOverview behavior. + * + * @param {HTMLTableElement} table + * The table element for the overview. + * @param {object} rowsData + * The data of the rows in the table. + * @param {object} rowHandlers + * Handlers to be added to the rows. + */ + attach: function (table, rowsData, rowHandlers) { + var tableDrag = Drupal.tableDrag[table.id]; + + // Add custom tabledrag callbacks. + tableDrag.onDrop = this.onDrop; + tableDrag.row.prototype.onSwap = this.onSwap; + + // Create row handlers. + $(table).find('tr.draggable').each(function () { + // Extract server-side data for the row. + var row = this; + if (row.id in rowsData) { + var data = rowsData[row.id]; + data.tableDrag = tableDrag; + + // Create the row handler, make it accessible from the DOM row + // element. + var rowHandler = new rowHandlers[data.rowHandler](row, data); + $(row).data('fieldUIRowHandler', rowHandler); + } + }); + }, + + /** + * Event handler to be attached to form inputs triggering a region change. + */ + onChange: function () { + var $trigger = $(this); + var $row = $trigger.closest('tr'); + var rowHandler = $row.data('fieldUIRowHandler'); + + var refreshRows = {}; + refreshRows[rowHandler.name] = $trigger.get(0); + + // Handle region change. + var region = rowHandler.getRegion(); + if (region !== rowHandler.region) { + // Remove parenting. + $row.find('select.js-field-parent').val(''); + // Let the row handler deal with the region change. + $.extend(refreshRows, rowHandler.regionChange(region)); + // Update the row region. + rowHandler.region = region; + } + + // Ajax-update the rows. + Drupal.fieldUIOverview.AJAXRefreshRows(refreshRows); + }, + + /** + * Lets row handlers react when a row is dropped into a new region. + */ + onDrop: function () { + var dragObject = this; + var row = dragObject.rowObject.element; + var $row = $(row); + var rowHandler = $row.data('fieldUIRowHandler'); + if (typeof rowHandler !== 'undefined') { + var regionRow = $row.prevAll('tr.region-message').get(0); + var region = regionRow.className.replace(/([^ ]+[ ]+)*region-([^ ]+)-message([ ]+[^ ]+)*/, '$2'); + + if (region !== rowHandler.region) { + // Let the row handler deal with the region change. + var refreshRows = rowHandler.regionChange(region); + // Update the row region. + rowHandler.region = region; + // Ajax-update the rows. + Drupal.fieldUIOverview.AJAXRefreshRows(refreshRows); + } + } + }, + + /** + * Refreshes placeholder rows in empty regions while a row is being dragged. + * + * Copied from block.js. + * + * @param {HTMLElement} draggedRow + * The tableDrag rowObject for the row being dragged. + */ + onSwap: function (draggedRow) { + var rowObject = this; + $(rowObject.table).find('tr.region-message').each(function () { + var $this = $(this); + // If the dragged row is in this region, but above the message row, swap + // it down one space. + if ($this.prev('tr').get(0) === rowObject.group[rowObject.group.length - 1]) { + // Prevent a recursion problem when using the keyboard to move rows + // up. + if ((rowObject.method !== 'keyboard' || rowObject.direction === 'down')) { + rowObject.swap('after', this); + } + } + // This region has become empty. + if ($this.next('tr').is(':not(.draggable)') || $this.next('tr').length === 0) { + $this.removeClass('region-populated').addClass('region-empty'); + } + // This region has become populated. + else if ($this.is('.region-empty')) { + $this.removeClass('region-empty').addClass('region-populated'); + } + }); + }, + + /** + * Triggers Ajax refresh of selected rows. + * + * The 'format type' selects can trigger a series of changes in child rows. + * The #ajax behavior is therefore not attached directly to the selects, but + * triggered manually through a hidden #ajax 'Refresh' button. + * + * @param {object} rows + * A hash object, whose keys are the names of the rows to refresh (they + * will receive the 'ajax-new-content' effect on the server side), and + * whose values are the DOM element in the row that should get an Ajax + * throbber. + */ + AJAXRefreshRows: function (rows) { + // Separate keys and values. + var rowNames = []; + var ajaxElements = []; + var rowName; + for (rowName in rows) { + if (rows.hasOwnProperty(rowName)) { + rowNames.push(rowName); + ajaxElements.push(rows[rowName]); + } + } + + if (rowNames.length) { + // Add a throbber next each of the ajaxElements. + $(ajaxElements).after('<div class="ajax-progress ajax-progress-throbber"><div class="throbber"> </div></div>'); + + // Fire the Ajax update. + $('input[name=refresh_rows]').val(rowNames.join(' ')); + $('input[data-drupal-selector="edit-refresh"]').trigger('mousedown'); + + // Disabled elements do not appear in POST ajax data, so we mark the + // elements disabled only after firing the request. + $(ajaxElements).prop('disabled', true); + } + } + }; + + /** + * Row handlers for the 'Manage display' screen. + * + * @namespace + */ + Drupal.fieldUIDisplayOverview = {}; + + /** + * Constructor for a 'field' row handler. + * + * This handler is used for both fields and 'extra fields' rows. + * + * @constructor + * + * @param {HTMLTableRowElement} row + * The row DOM element. + * @param {object} data + * Additional data to be populated in the constructed object. + * + * @return {Drupal.fieldUIDisplayOverview.field} + * The field row handler constructed. + */ + Drupal.fieldUIDisplayOverview.field = function (row, data) { + this.row = row; + this.name = data.name; + this.region = data.region; + this.tableDrag = data.tableDrag; + this.defaultPlugin = data.defaultPlugin; + + // Attach change listener to the 'plugin type' select. + this.$pluginSelect = $(row).find('.field-plugin-type'); + this.$pluginSelect.on('change', Drupal.fieldUIOverview.onChange); + + // Attach change listener to the 'region' select. + this.$regionSelect = $(row).find('select.field-region'); + this.$regionSelect.on('change', Drupal.fieldUIOverview.onChange); + + return this; + }; + + Drupal.fieldUIDisplayOverview.field.prototype = { + + /** + * Returns the region corresponding to the current form values of the row. + * + * @return {string} + * Either 'hidden' or 'content'. + */ + getRegion: function () { + return this.$regionSelect.val(); + }, + + /** + * Reacts to a row being changed regions. + * + * This function is called when the row is moved to a different region, as + * a + * result of either : + * - a drag-and-drop action (the row's form elements then probably need to + * be updated accordingly) + * - user input in one of the form elements watched by the + * {@link Drupal.fieldUIOverview.onChange} change listener. + * + * @param {string} region + * The name of the new region for the row. + * + * @return {object} + * A hash object indicating which rows should be Ajax-updated as a result + * of the change, in the format expected by + * {@link Drupal.fieldUIOverview.AJAXRefreshRows}. + */ + regionChange: function (region) { + // Replace dashes with underscores. + region = region.replace(/-/g, '_'); + + // Set the region of the select list. + this.$regionSelect.val(region); + + // Restore the formatter back to the default formatter. Pseudo-fields + // do not have default formatters, we just return to 'visible' for + // those. + var value = (typeof this.defaultPlugin !== 'undefined') ? this.defaultPlugin : this.$pluginSelect.find('option').val(); + + if (typeof value !== 'undefined') { + this.$pluginSelect.val(value); + } + + var refreshRows = {}; + refreshRows[this.name] = this.$pluginSelect.get(0); + + return refreshRows; + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/field_ui/field_ui.js b/core/modules/field_ui/field_ui.js index 30e983a14348..58f832c02124 100644 --- a/core/modules/field_ui/field_ui.js +++ b/core/modules/field_ui/field_ui.js @@ -1,55 +1,36 @@ /** - * @file - * Attaches the behaviors for the Field UI module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/field_ui/field_ui.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Adds behaviors to the field storage add form. - */ Drupal.behaviors.fieldUIFieldStorageAddForm = { - attach: function (context) { + attach: function attach(context) { var $form = $(context).find('[data-drupal-selector="field-ui-field-storage-add-form"]').once('field_ui_add'); if ($form.length) { - // Add a few 'js-form-required' and 'form-required' css classes here. - // We can not use the Form API '#required' property because both label - // elements for "add new" and "re-use existing" can never be filled and - // submitted at the same time. The actual validation will happen - // server-side. - $form.find( - '.js-form-item-label label,' + - '.js-form-item-field-name label,' + - '.js-form-item-existing-storage-label label') - .addClass('js-form-required form-required'); + $form.find('.js-form-item-label label,' + '.js-form-item-field-name label,' + '.js-form-item-existing-storage-label label').addClass('js-form-required form-required'); var $newFieldType = $form.find('select[name="new_storage_type"]'); var $existingStorageName = $form.find('select[name="existing_storage_name"]'); var $existingStorageLabel = $form.find('input[name="existing_storage_label"]'); - // When the user selects a new field type, clear the "existing field" - // selection. $newFieldType.on('change', function () { if ($(this).val() !== '') { - // Reset the "existing storage name" selection. $existingStorageName.val('').trigger('change'); } }); - // When the user selects an existing storage name, clear the "new field - // type" selection and populate the 'existing_storage_label' element. $existingStorageName.on('change', function () { var value = $(this).val(); if (value !== '') { - // Reset the "new field type" selection. $newFieldType.val('').trigger('change'); - // Pre-populate the "existing storage label" element. if (typeof drupalSettings.existingFieldLabels[value] !== 'undefined') { $existingStorageLabel.val(drupalSettings.existingFieldLabels[value]); } @@ -59,68 +40,34 @@ } }; - /** - * Attaches the fieldUIOverview behavior. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the fieldUIOverview behavior. - * - * @see Drupal.fieldUIOverview.attach - */ Drupal.behaviors.fieldUIDisplayOverview = { - attach: function (context, settings) { + attach: function attach(context, settings) { $(context).find('table#field-display-overview').once('field-display-overview').each(function () { Drupal.fieldUIOverview.attach(this, settings.fieldUIRowsData, Drupal.fieldUIDisplayOverview); }); } }; - /** - * Namespace for the field UI overview. - * - * @namespace - */ Drupal.fieldUIOverview = { - - /** - * Attaches the fieldUIOverview behavior. - * - * @param {HTMLTableElement} table - * The table element for the overview. - * @param {object} rowsData - * The data of the rows in the table. - * @param {object} rowHandlers - * Handlers to be added to the rows. - */ - attach: function (table, rowsData, rowHandlers) { + attach: function attach(table, rowsData, rowHandlers) { var tableDrag = Drupal.tableDrag[table.id]; - // Add custom tabledrag callbacks. tableDrag.onDrop = this.onDrop; tableDrag.row.prototype.onSwap = this.onSwap; - // Create row handlers. $(table).find('tr.draggable').each(function () { - // Extract server-side data for the row. var row = this; if (row.id in rowsData) { var data = rowsData[row.id]; data.tableDrag = tableDrag; - // Create the row handler, make it accessible from the DOM row - // element. var rowHandler = new rowHandlers[data.rowHandler](row, data); $(row).data('fieldUIRowHandler', rowHandler); } }); }, - /** - * Event handler to be attached to form inputs triggering a region change. - */ - onChange: function () { + onChange: function onChange() { var $trigger = $(this); var $row = $trigger.closest('tr'); var rowHandler = $row.data('fieldUIRowHandler'); @@ -128,25 +75,19 @@ var refreshRows = {}; refreshRows[rowHandler.name] = $trigger.get(0); - // Handle region change. var region = rowHandler.getRegion(); if (region !== rowHandler.region) { - // Remove parenting. $row.find('select.js-field-parent').val(''); - // Let the row handler deal with the region change. + $.extend(refreshRows, rowHandler.regionChange(region)); - // Update the row region. + rowHandler.region = region; } - // Ajax-update the rows. Drupal.fieldUIOverview.AJAXRefreshRows(refreshRows); }, - /** - * Lets row handlers react when a row is dropped into a new region. - */ - onDrop: function () { + onDrop: function onDrop() { var dragObject = this; var row = dragObject.rowObject.element; var $row = $(row); @@ -156,63 +97,35 @@ var region = regionRow.className.replace(/([^ ]+[ ]+)*region-([^ ]+)-message([ ]+[^ ]+)*/, '$2'); if (region !== rowHandler.region) { - // Let the row handler deal with the region change. var refreshRows = rowHandler.regionChange(region); - // Update the row region. + rowHandler.region = region; - // Ajax-update the rows. + Drupal.fieldUIOverview.AJAXRefreshRows(refreshRows); } } }, - /** - * Refreshes placeholder rows in empty regions while a row is being dragged. - * - * Copied from block.js. - * - * @param {HTMLElement} draggedRow - * The tableDrag rowObject for the row being dragged. - */ - onSwap: function (draggedRow) { + onSwap: function onSwap(draggedRow) { var rowObject = this; $(rowObject.table).find('tr.region-message').each(function () { var $this = $(this); - // If the dragged row is in this region, but above the message row, swap - // it down one space. + if ($this.prev('tr').get(0) === rowObject.group[rowObject.group.length - 1]) { - // Prevent a recursion problem when using the keyboard to move rows - // up. - if ((rowObject.method !== 'keyboard' || rowObject.direction === 'down')) { + if (rowObject.method !== 'keyboard' || rowObject.direction === 'down') { rowObject.swap('after', this); } } - // This region has become empty. + if ($this.next('tr').is(':not(.draggable)') || $this.next('tr').length === 0) { $this.removeClass('region-populated').addClass('region-empty'); - } - // This region has become populated. - else if ($this.is('.region-empty')) { - $this.removeClass('region-empty').addClass('region-populated'); - } + } else if ($this.is('.region-empty')) { + $this.removeClass('region-empty').addClass('region-populated'); + } }); }, - /** - * Triggers Ajax refresh of selected rows. - * - * The 'format type' selects can trigger a series of changes in child rows. - * The #ajax behavior is therefore not attached directly to the selects, but - * triggered manually through a hidden #ajax 'Refresh' button. - * - * @param {object} rows - * A hash object, whose keys are the names of the rows to refresh (they - * will receive the 'ajax-new-content' effect on the server side), and - * whose values are the DOM element in the row that should get an Ajax - * throbber. - */ - AJAXRefreshRows: function (rows) { - // Separate keys and values. + AJAXRefreshRows: function AJAXRefreshRows(rows) { var rowNames = []; var ajaxElements = []; var rowName; @@ -224,42 +137,18 @@ } if (rowNames.length) { - // Add a throbber next each of the ajaxElements. $(ajaxElements).after('<div class="ajax-progress ajax-progress-throbber"><div class="throbber"> </div></div>'); - // Fire the Ajax update. $('input[name=refresh_rows]').val(rowNames.join(' ')); $('input[data-drupal-selector="edit-refresh"]').trigger('mousedown'); - // Disabled elements do not appear in POST ajax data, so we mark the - // elements disabled only after firing the request. $(ajaxElements).prop('disabled', true); } } }; - /** - * Row handlers for the 'Manage display' screen. - * - * @namespace - */ Drupal.fieldUIDisplayOverview = {}; - /** - * Constructor for a 'field' row handler. - * - * This handler is used for both fields and 'extra fields' rows. - * - * @constructor - * - * @param {HTMLTableRowElement} row - * The row DOM element. - * @param {object} data - * Additional data to be populated in the constructed object. - * - * @return {Drupal.fieldUIDisplayOverview.field} - * The field row handler constructed. - */ Drupal.fieldUIDisplayOverview.field = function (row, data) { this.row = row; this.name = data.name; @@ -267,11 +156,9 @@ this.tableDrag = data.tableDrag; this.defaultPlugin = data.defaultPlugin; - // Attach change listener to the 'plugin type' select. this.$pluginSelect = $(row).find('.field-plugin-type'); this.$pluginSelect.on('change', Drupal.fieldUIOverview.onChange); - // Attach change listener to the 'region' select. this.$regionSelect = $(row).find('select.field-region'); this.$regionSelect.on('change', Drupal.fieldUIOverview.onChange); @@ -279,47 +166,16 @@ }; Drupal.fieldUIDisplayOverview.field.prototype = { - - /** - * Returns the region corresponding to the current form values of the row. - * - * @return {string} - * Either 'hidden' or 'content'. - */ - getRegion: function () { + getRegion: function getRegion() { return this.$regionSelect.val(); }, - /** - * Reacts to a row being changed regions. - * - * This function is called when the row is moved to a different region, as - * a - * result of either : - * - a drag-and-drop action (the row's form elements then probably need to - * be updated accordingly) - * - user input in one of the form elements watched by the - * {@link Drupal.fieldUIOverview.onChange} change listener. - * - * @param {string} region - * The name of the new region for the row. - * - * @return {object} - * A hash object indicating which rows should be Ajax-updated as a result - * of the change, in the format expected by - * {@link Drupal.fieldUIOverview.AJAXRefreshRows}. - */ - regionChange: function (region) { - // Replace dashes with underscores. + regionChange: function regionChange(region) { region = region.replace(/-/g, '_'); - // Set the region of the select list. this.$regionSelect.val(region); - // Restore the formatter back to the default formatter. Pseudo-fields - // do not have default formatters, we just return to 'visible' for - // those. - var value = (typeof this.defaultPlugin !== 'undefined') ? this.defaultPlugin : this.$pluginSelect.find('option').val(); + var value = typeof this.defaultPlugin !== 'undefined' ? this.defaultPlugin : this.$pluginSelect.find('option').val(); if (typeof value !== 'undefined') { this.$pluginSelect.val(value); @@ -331,5 +187,4 @@ return refreshRows; } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/file/file.es6.js b/core/modules/file/file.es6.js new file mode 100644 index 000000000000..8ed377eec3d8 --- /dev/null +++ b/core/modules/file/file.es6.js @@ -0,0 +1,257 @@ +/** + * @file + * Provides JavaScript additions to the managed file field type. + * + * This file provides progress bar support (if available), popup windows for + * file previews, and disabling of other file fields during Ajax uploads (which + * prevents separate file fields from accidentally uploading files). + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Attach behaviors to the file fields passed in the settings. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches validation for file extensions. + * @prop {Drupal~behaviorDetach} detach + * Detaches validation for file extensions. + */ + Drupal.behaviors.fileValidateAutoAttach = { + attach: function (context, settings) { + var $context = $(context); + var elements; + + function initFileValidation(selector) { + $context.find(selector) + .once('fileValidate') + .on('change.fileValidate', {extensions: elements[selector]}, Drupal.file.validateExtension); + } + + if (settings.file && settings.file.elements) { + elements = settings.file.elements; + Object.keys(elements).forEach(initFileValidation); + } + }, + detach: function (context, settings, trigger) { + var $context = $(context); + var elements; + + function removeFileValidation(selector) { + $context.find(selector) + .removeOnce('fileValidate') + .off('change.fileValidate', Drupal.file.validateExtension); + } + + if (trigger === 'unload' && settings.file && settings.file.elements) { + elements = settings.file.elements; + Object.keys(elements).forEach(removeFileValidation); + } + } + }; + + /** + * Attach behaviors to file element auto upload. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches triggers for the upload button. + * @prop {Drupal~behaviorDetach} detach + * Detaches auto file upload trigger. + */ + Drupal.behaviors.fileAutoUpload = { + attach: function (context) { + $(context).find('input[type="file"]').once('auto-file-upload').on('change.autoFileUpload', Drupal.file.triggerUploadButton); + }, + detach: function (context, setting, trigger) { + if (trigger === 'unload') { + $(context).find('input[type="file"]').removeOnce('auto-file-upload').off('.autoFileUpload'); + } + } + }; + + /** + * Attach behaviors to the file upload and remove buttons. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches form submit events. + * @prop {Drupal~behaviorDetach} detach + * Detaches form submit events. + */ + Drupal.behaviors.fileButtons = { + attach: function (context) { + var $context = $(context); + $context.find('.js-form-submit').on('mousedown', Drupal.file.disableFields); + $context.find('.js-form-managed-file .js-form-submit').on('mousedown', Drupal.file.progressBar); + }, + detach: function (context) { + var $context = $(context); + $context.find('.js-form-submit').off('mousedown', Drupal.file.disableFields); + $context.find('.js-form-managed-file .js-form-submit').off('mousedown', Drupal.file.progressBar); + } + }; + + /** + * Attach behaviors to links within managed file elements for preview windows. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches triggers. + * @prop {Drupal~behaviorDetach} detach + * Detaches triggers. + */ + Drupal.behaviors.filePreviewLinks = { + attach: function (context) { + $(context).find('div.js-form-managed-file .file a').on('click', Drupal.file.openInNewWindow); + }, + detach: function (context) { + $(context).find('div.js-form-managed-file .file a').off('click', Drupal.file.openInNewWindow); + } + }; + + /** + * File upload utility functions. + * + * @namespace + */ + Drupal.file = Drupal.file || { + + /** + * Client-side file input validation of file extensions. + * + * @name Drupal.file.validateExtension + * + * @param {jQuery.Event} event + * The event triggered. For example `change.fileValidate`. + */ + validateExtension: function (event) { + event.preventDefault(); + // Remove any previous errors. + $('.file-upload-js-error').remove(); + + // Add client side validation for the input[type=file]. + var extensionPattern = event.data.extensions.replace(/,\s*/g, '|'); + if (extensionPattern.length > 1 && this.value.length > 0) { + var acceptableMatch = new RegExp('\\.(' + extensionPattern + ')$', 'gi'); + if (!acceptableMatch.test(this.value)) { + var error = Drupal.t('The selected file %filename cannot be uploaded. Only files with the following extensions are allowed: %extensions.', { + // According to the specifications of HTML5, a file upload control + // should not reveal the real local path to the file that a user + // has selected. Some web browsers implement this restriction by + // replacing the local path with "C:\fakepath\", which can cause + // confusion by leaving the user thinking perhaps Drupal could not + // find the file because it messed up the file path. To avoid this + // confusion, therefore, we strip out the bogus fakepath string. + '%filename': this.value.replace('C:\\fakepath\\', ''), + '%extensions': extensionPattern.replace(/\|/g, ', ') + }); + $(this).closest('div.js-form-managed-file').prepend('<div class="messages messages--error file-upload-js-error" aria-live="polite">' + error + '</div>'); + this.value = ''; + // Cancel all other change event handlers. + event.stopImmediatePropagation(); + } + } + }, + + /** + * Trigger the upload_button mouse event to auto-upload as a managed file. + * + * @name Drupal.file.triggerUploadButton + * + * @param {jQuery.Event} event + * The event triggered. For example `change.autoFileUpload`. + */ + triggerUploadButton: function (event) { + $(event.target).closest('.js-form-managed-file').find('.js-form-submit').trigger('mousedown'); + }, + + /** + * Prevent file uploads when using buttons not intended to upload. + * + * @name Drupal.file.disableFields + * + * @param {jQuery.Event} event + * The event triggered, most likely a `mousedown` event. + */ + disableFields: function (event) { + var $clickedButton = $(this).findOnce('ajax'); + + // Only disable upload fields for Ajax buttons. + if (!$clickedButton.length) { + return; + } + + // Check if we're working with an "Upload" button. + var $enabledFields = []; + if ($clickedButton.closest('div.js-form-managed-file').length > 0) { + $enabledFields = $clickedButton.closest('div.js-form-managed-file').find('input.js-form-file'); + } + + // Temporarily disable upload fields other than the one we're currently + // working with. Filter out fields that are already disabled so that they + // do not get enabled when we re-enable these fields at the end of + // behavior processing. Re-enable in a setTimeout set to a relatively + // short amount of time (1 second). All the other mousedown handlers + // (like Drupal's Ajax behaviors) are executed before any timeout + // functions are called, so we don't have to worry about the fields being + // re-enabled too soon. @todo If the previous sentence is true, why not + // set the timeout to 0? + var $fieldsToTemporarilyDisable = $('div.js-form-managed-file input.js-form-file').not($enabledFields).not(':disabled'); + $fieldsToTemporarilyDisable.prop('disabled', true); + setTimeout(function () { + $fieldsToTemporarilyDisable.prop('disabled', false); + }, 1000); + }, + + /** + * Add progress bar support if possible. + * + * @name Drupal.file.progressBar + * + * @param {jQuery.Event} event + * The event triggered, most likely a `mousedown` event. + */ + progressBar: function (event) { + var $clickedButton = $(this); + var $progressId = $clickedButton.closest('div.js-form-managed-file').find('input.file-progress'); + if ($progressId.length) { + var originalName = $progressId.attr('name'); + + // Replace the name with the required identifier. + $progressId.attr('name', originalName.match(/APC_UPLOAD_PROGRESS|UPLOAD_IDENTIFIER/)[0]); + + // Restore the original name after the upload begins. + setTimeout(function () { + $progressId.attr('name', originalName); + }, 1000); + } + // Show the progress bar if the upload takes longer than half a second. + setTimeout(function () { + $clickedButton.closest('div.js-form-managed-file').find('div.ajax-progress-bar').slideDown(); + }, 500); + }, + + /** + * Open links to files within forms in a new window. + * + * @name Drupal.file.openInNewWindow + * + * @param {jQuery.Event} event + * The event triggered, most likely a `click` event. + */ + openInNewWindow: function (event) { + event.preventDefault(); + $(this).attr('target', '_blank'); + window.open(this.href, 'filePreview', 'toolbar=0,scrollbars=1,location=1,statusbar=1,menubar=0,resizable=1,width=500,height=550'); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/file/file.js b/core/modules/file/file.js index 8ed377eec3d8..c65709a7ef6a 100644 --- a/core/modules/file/file.js +++ b/core/modules/file/file.js @@ -1,35 +1,22 @@ /** - * @file - * Provides JavaScript additions to the managed file field type. - * - * This file provides progress bar support (if available), popup windows for - * file previews, and disabling of other file fields during Ajax uploads (which - * prevents separate file fields from accidentally uploading files). - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/file/file.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Attach behaviors to the file fields passed in the settings. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches validation for file extensions. - * @prop {Drupal~behaviorDetach} detach - * Detaches validation for file extensions. - */ Drupal.behaviors.fileValidateAutoAttach = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $context = $(context); var elements; function initFileValidation(selector) { - $context.find(selector) - .once('fileValidate') - .on('change.fileValidate', {extensions: elements[selector]}, Drupal.file.validateExtension); + $context.find(selector).once('fileValidate').on('change.fileValidate', { extensions: elements[selector] }, Drupal.file.validateExtension); } if (settings.file && settings.file.elements) { @@ -37,14 +24,12 @@ Object.keys(elements).forEach(initFileValidation); } }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { var $context = $(context); var elements; function removeFileValidation(selector) { - $context.find(selector) - .removeOnce('fileValidate') - .off('change.fileValidate', Drupal.file.validateExtension); + $context.find(selector).removeOnce('fileValidate').off('change.fileValidate', Drupal.file.validateExtension); } if (trigger === 'unload' && settings.file && settings.file.elements) { @@ -54,156 +39,77 @@ } }; - /** - * Attach behaviors to file element auto upload. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches triggers for the upload button. - * @prop {Drupal~behaviorDetach} detach - * Detaches auto file upload trigger. - */ Drupal.behaviors.fileAutoUpload = { - attach: function (context) { + attach: function attach(context) { $(context).find('input[type="file"]').once('auto-file-upload').on('change.autoFileUpload', Drupal.file.triggerUploadButton); }, - detach: function (context, setting, trigger) { + detach: function detach(context, setting, trigger) { if (trigger === 'unload') { $(context).find('input[type="file"]').removeOnce('auto-file-upload').off('.autoFileUpload'); } } }; - /** - * Attach behaviors to the file upload and remove buttons. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches form submit events. - * @prop {Drupal~behaviorDetach} detach - * Detaches form submit events. - */ Drupal.behaviors.fileButtons = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); $context.find('.js-form-submit').on('mousedown', Drupal.file.disableFields); $context.find('.js-form-managed-file .js-form-submit').on('mousedown', Drupal.file.progressBar); }, - detach: function (context) { + detach: function detach(context) { var $context = $(context); $context.find('.js-form-submit').off('mousedown', Drupal.file.disableFields); $context.find('.js-form-managed-file .js-form-submit').off('mousedown', Drupal.file.progressBar); } }; - /** - * Attach behaviors to links within managed file elements for preview windows. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches triggers. - * @prop {Drupal~behaviorDetach} detach - * Detaches triggers. - */ Drupal.behaviors.filePreviewLinks = { - attach: function (context) { + attach: function attach(context) { $(context).find('div.js-form-managed-file .file a').on('click', Drupal.file.openInNewWindow); }, - detach: function (context) { + detach: function detach(context) { $(context).find('div.js-form-managed-file .file a').off('click', Drupal.file.openInNewWindow); } }; - /** - * File upload utility functions. - * - * @namespace - */ Drupal.file = Drupal.file || { - - /** - * Client-side file input validation of file extensions. - * - * @name Drupal.file.validateExtension - * - * @param {jQuery.Event} event - * The event triggered. For example `change.fileValidate`. - */ - validateExtension: function (event) { + validateExtension: function validateExtension(event) { event.preventDefault(); - // Remove any previous errors. + $('.file-upload-js-error').remove(); - // Add client side validation for the input[type=file]. var extensionPattern = event.data.extensions.replace(/,\s*/g, '|'); if (extensionPattern.length > 1 && this.value.length > 0) { var acceptableMatch = new RegExp('\\.(' + extensionPattern + ')$', 'gi'); if (!acceptableMatch.test(this.value)) { var error = Drupal.t('The selected file %filename cannot be uploaded. Only files with the following extensions are allowed: %extensions.', { - // According to the specifications of HTML5, a file upload control - // should not reveal the real local path to the file that a user - // has selected. Some web browsers implement this restriction by - // replacing the local path with "C:\fakepath\", which can cause - // confusion by leaving the user thinking perhaps Drupal could not - // find the file because it messed up the file path. To avoid this - // confusion, therefore, we strip out the bogus fakepath string. '%filename': this.value.replace('C:\\fakepath\\', ''), '%extensions': extensionPattern.replace(/\|/g, ', ') }); $(this).closest('div.js-form-managed-file').prepend('<div class="messages messages--error file-upload-js-error" aria-live="polite">' + error + '</div>'); this.value = ''; - // Cancel all other change event handlers. + event.stopImmediatePropagation(); } } }, - /** - * Trigger the upload_button mouse event to auto-upload as a managed file. - * - * @name Drupal.file.triggerUploadButton - * - * @param {jQuery.Event} event - * The event triggered. For example `change.autoFileUpload`. - */ - triggerUploadButton: function (event) { + triggerUploadButton: function triggerUploadButton(event) { $(event.target).closest('.js-form-managed-file').find('.js-form-submit').trigger('mousedown'); }, - /** - * Prevent file uploads when using buttons not intended to upload. - * - * @name Drupal.file.disableFields - * - * @param {jQuery.Event} event - * The event triggered, most likely a `mousedown` event. - */ - disableFields: function (event) { + disableFields: function disableFields(event) { var $clickedButton = $(this).findOnce('ajax'); - // Only disable upload fields for Ajax buttons. if (!$clickedButton.length) { return; } - // Check if we're working with an "Upload" button. var $enabledFields = []; if ($clickedButton.closest('div.js-form-managed-file').length > 0) { $enabledFields = $clickedButton.closest('div.js-form-managed-file').find('input.js-form-file'); } - // Temporarily disable upload fields other than the one we're currently - // working with. Filter out fields that are already disabled so that they - // do not get enabled when we re-enable these fields at the end of - // behavior processing. Re-enable in a setTimeout set to a relatively - // short amount of time (1 second). All the other mousedown handlers - // (like Drupal's Ajax behaviors) are executed before any timeout - // functions are called, so we don't have to worry about the fields being - // re-enabled too soon. @todo If the previous sentence is true, why not - // set the timeout to 0? var $fieldsToTemporarilyDisable = $('div.js-form-managed-file input.js-form-file').not($enabledFields).not(':disabled'); $fieldsToTemporarilyDisable.prop('disabled', true); setTimeout(function () { @@ -211,47 +117,28 @@ }, 1000); }, - /** - * Add progress bar support if possible. - * - * @name Drupal.file.progressBar - * - * @param {jQuery.Event} event - * The event triggered, most likely a `mousedown` event. - */ - progressBar: function (event) { + progressBar: function progressBar(event) { var $clickedButton = $(this); var $progressId = $clickedButton.closest('div.js-form-managed-file').find('input.file-progress'); if ($progressId.length) { var originalName = $progressId.attr('name'); - // Replace the name with the required identifier. $progressId.attr('name', originalName.match(/APC_UPLOAD_PROGRESS|UPLOAD_IDENTIFIER/)[0]); - // Restore the original name after the upload begins. setTimeout(function () { $progressId.attr('name', originalName); }, 1000); } - // Show the progress bar if the upload takes longer than half a second. + setTimeout(function () { $clickedButton.closest('div.js-form-managed-file').find('div.ajax-progress-bar').slideDown(); }, 500); }, - /** - * Open links to files within forms in a new window. - * - * @name Drupal.file.openInNewWindow - * - * @param {jQuery.Event} event - * The event triggered, most likely a `click` event. - */ - openInNewWindow: function (event) { + openInNewWindow: function openInNewWindow(event) { event.preventDefault(); $(this).attr('target', '_blank'); window.open(this.href, 'filePreview', 'toolbar=0,scrollbars=1,location=1,statusbar=1,menubar=0,resizable=1,width=500,height=550'); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/filter/filter.admin.es6.js b/core/modules/filter/filter.admin.es6.js new file mode 100644 index 000000000000..31e0582c1899 --- /dev/null +++ b/core/modules/filter/filter.admin.es6.js @@ -0,0 +1,69 @@ +/** + * @file + * Attaches administration-specific behavior for the Filter module. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Displays and updates the status of filters on the admin page. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behaviors to the filter admin view. + */ + Drupal.behaviors.filterStatus = { + attach: function (context, settings) { + var $context = $(context); + $context.find('#filters-status-wrapper input.form-checkbox').once('filter-status').each(function () { + var $checkbox = $(this); + // Retrieve the tabledrag row belonging to this filter. + var $row = $context.find('#' + $checkbox.attr('id').replace(/-status$/, '-weight')).closest('tr'); + // Retrieve the vertical tab belonging to this filter. + var $filterSettings = $context.find('#' + $checkbox.attr('id').replace(/-status$/, '-settings')); + var filterSettingsTab = $filterSettings.data('verticalTab'); + + // Bind click handler to this checkbox to conditionally show and hide + // the filter's tableDrag row and vertical tab pane. + $checkbox.on('click.filterUpdate', function () { + if ($checkbox.is(':checked')) { + $row.show(); + if (filterSettingsTab) { + filterSettingsTab.tabShow().updateSummary(); + } + else { + // On very narrow viewports, Vertical Tabs are disabled. + $filterSettings.show(); + } + } + else { + $row.hide(); + if (filterSettingsTab) { + filterSettingsTab.tabHide().updateSummary(); + } + else { + // On very narrow viewports, Vertical Tabs are disabled. + $filterSettings.hide(); + } + } + // Restripe table after toggling visibility of table row. + Drupal.tableDrag['filter-order'].restripeTable(); + }); + + // Attach summary for configurable filters (only for screen readers). + if (filterSettingsTab) { + filterSettingsTab.details.drupalSetSummary(function (tabContext) { + return $checkbox.is(':checked') ? Drupal.t('Enabled') : Drupal.t('Disabled'); + }); + } + + // Trigger our bound click handler to update elements to initial state. + $checkbox.triggerHandler('click.filterUpdate'); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/filter/filter.admin.js b/core/modules/filter/filter.admin.js index 31e0582c1899..8fb815e01b03 100644 --- a/core/modules/filter/filter.admin.js +++ b/core/modules/filter/filter.admin.js @@ -1,69 +1,54 @@ /** - * @file - * Attaches administration-specific behavior for the Filter module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/filter/filter.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Displays and updates the status of filters on the admin page. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behaviors to the filter admin view. - */ Drupal.behaviors.filterStatus = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $context = $(context); $context.find('#filters-status-wrapper input.form-checkbox').once('filter-status').each(function () { var $checkbox = $(this); - // Retrieve the tabledrag row belonging to this filter. + var $row = $context.find('#' + $checkbox.attr('id').replace(/-status$/, '-weight')).closest('tr'); - // Retrieve the vertical tab belonging to this filter. + var $filterSettings = $context.find('#' + $checkbox.attr('id').replace(/-status$/, '-settings')); var filterSettingsTab = $filterSettings.data('verticalTab'); - // Bind click handler to this checkbox to conditionally show and hide - // the filter's tableDrag row and vertical tab pane. $checkbox.on('click.filterUpdate', function () { if ($checkbox.is(':checked')) { $row.show(); if (filterSettingsTab) { filterSettingsTab.tabShow().updateSummary(); - } - else { - // On very narrow viewports, Vertical Tabs are disabled. + } else { $filterSettings.show(); } - } - else { + } else { $row.hide(); if (filterSettingsTab) { filterSettingsTab.tabHide().updateSummary(); - } - else { - // On very narrow viewports, Vertical Tabs are disabled. + } else { $filterSettings.hide(); } } - // Restripe table after toggling visibility of table row. + Drupal.tableDrag['filter-order'].restripeTable(); }); - // Attach summary for configurable filters (only for screen readers). if (filterSettingsTab) { filterSettingsTab.details.drupalSetSummary(function (tabContext) { return $checkbox.is(':checked') ? Drupal.t('Enabled') : Drupal.t('Disabled'); }); } - // Trigger our bound click handler to update elements to initial state. $checkbox.triggerHandler('click.filterUpdate'); }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/filter/filter.es6.js b/core/modules/filter/filter.es6.js new file mode 100644 index 000000000000..b79104733edc --- /dev/null +++ b/core/modules/filter/filter.es6.js @@ -0,0 +1,39 @@ +/** + * @file + * Attaches behavior for the Filter module. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Displays the guidelines of the selected text format automatically. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for updating filter guidelines. + */ + Drupal.behaviors.filterGuidelines = { + attach: function (context) { + + function updateFilterGuidelines(event) { + var $this = $(event.target); + var value = $this.val(); + $this.closest('.filter-wrapper') + .find('.filter-guidelines-item').hide() + .filter('.filter-guidelines-' + value).show(); + } + + $(context).find('.filter-guidelines').once('filter-guidelines') + .find(':header').hide() + .closest('.filter-wrapper').find('select.filter-list') + .on('change.filterGuidelines', updateFilterGuidelines) + // Need to trigger the namespaced event to avoid triggering formUpdated + // when initializing the select. + .trigger('change.filterGuidelines'); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/filter/filter.filter_html.admin.es6.js b/core/modules/filter/filter.filter_html.admin.es6.js new file mode 100644 index 000000000000..7ddca90bba84 --- /dev/null +++ b/core/modules/filter/filter.filter_html.admin.es6.js @@ -0,0 +1,328 @@ +/** + * @file + * Attaches behavior for updating filter_html's settings automatically. + */ + +(function ($, Drupal, _, document) { + + 'use strict'; + + if (Drupal.filterConfiguration) { + + /** + * Implement a live setting parser to prevent text editors from automatically + * enabling buttons that are not allowed by this filter's configuration. + * + * @namespace + */ + Drupal.filterConfiguration.liveSettingParsers.filter_html = { + + /** + * @return {Array} + * An array of filter rules. + */ + getRules: function () { + var currentValue = $('#edit-filters-filter-html-settings-allowed-html').val(); + var rules = Drupal.behaviors.filterFilterHtmlUpdating._parseSetting(currentValue); + + // Build a FilterHTMLRule that reflects the hard-coded behavior that + // strips all "style" attribute and all "on*" attributes. + var rule = new Drupal.FilterHTMLRule(); + rule.restrictedTags.tags = ['*']; + rule.restrictedTags.forbidden.attributes = ['style', 'on*']; + rules.push(rule); + + return rules; + } + }; + } + + /** + * Displays and updates what HTML tags are allowed to use in a filter. + * + * @type {Drupal~behavior} + * + * @todo Remove everything but 'attach' and 'detach' and make a proper object. + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for updating allowed HTML tags. + */ + Drupal.behaviors.filterFilterHtmlUpdating = { + + // The form item contains the "Allowed HTML tags" setting. + $allowedHTMLFormItem: null, + + // The description for the "Allowed HTML tags" field. + $allowedHTMLDescription: null, + + /** + * The parsed, user-entered tag list of $allowedHTMLFormItem + * + * @var {Object.<string, Drupal.FilterHTMLRule>} + */ + userTags: {}, + + // The auto-created tag list thus far added. + autoTags: null, + + // Track which new features have been added to the text editor. + newFeatures: {}, + + attach: function (context, settings) { + var that = this; + $(context).find('[name="filters[filter_html][settings][allowed_html]"]').once('filter-filter_html-updating').each(function () { + that.$allowedHTMLFormItem = $(this); + that.$allowedHTMLDescription = that.$allowedHTMLFormItem.closest('.js-form-item').find('.description'); + that.userTags = that._parseSetting(this.value); + + // Update the new allowed tags based on added text editor features. + $(document) + .on('drupalEditorFeatureAdded', function (e, feature) { + that.newFeatures[feature.name] = feature.rules; + that._updateAllowedTags(); + }) + .on('drupalEditorFeatureModified', function (e, feature) { + if (that.newFeatures.hasOwnProperty(feature.name)) { + that.newFeatures[feature.name] = feature.rules; + that._updateAllowedTags(); + } + }) + .on('drupalEditorFeatureRemoved', function (e, feature) { + if (that.newFeatures.hasOwnProperty(feature.name)) { + delete that.newFeatures[feature.name]; + that._updateAllowedTags(); + } + }); + + // When the allowed tags list is manually changed, update userTags. + that.$allowedHTMLFormItem.on('change.updateUserTags', function () { + that.userTags = _.difference(that._parseSetting(this.value), that.autoTags); + }); + }); + }, + + /** + * Updates the "Allowed HTML tags" setting and shows an informative message. + */ + _updateAllowedTags: function () { + // Update the list of auto-created tags. + this.autoTags = this._calculateAutoAllowedTags(this.userTags, this.newFeatures); + + // Remove any previous auto-created tag message. + this.$allowedHTMLDescription.find('.editor-update-message').remove(); + + // If any auto-created tags: insert message and update form item. + if (!_.isEmpty(this.autoTags)) { + this.$allowedHTMLDescription.append(Drupal.theme('filterFilterHTMLUpdateMessage', this.autoTags)); + var userTagsWithoutOverrides = _.omit(this.userTags, _.keys(this.autoTags)); + this.$allowedHTMLFormItem.val(this._generateSetting(userTagsWithoutOverrides) + ' ' + this._generateSetting(this.autoTags)); + } + // Restore to original state. + else { + this.$allowedHTMLFormItem.val(this._generateSetting(this.userTags)); + } + }, + + /** + * Calculates which HTML tags the added text editor buttons need to work. + * + * The filter_html filter is only concerned with the required tags, not with + * any properties, nor with each feature's "allowed" tags. + * + * @param {Array} userAllowedTags + * The list of user-defined allowed tags. + * @param {object} newFeatures + * A list of {@link Drupal.EditorFeature} objects' rules, keyed by + * their name. + * + * @return {Array} + * A list of new allowed tags. + */ + _calculateAutoAllowedTags: function (userAllowedTags, newFeatures) { + var featureName; + var feature; + var featureRule; + var filterRule; + var tag; + var editorRequiredTags = {}; + // Map the newly added Text Editor features to Drupal.FilterHtmlRule + // objects (to allow comparing userTags with autoTags). + for (featureName in newFeatures) { + if (newFeatures.hasOwnProperty(featureName)) { + feature = newFeatures[featureName]; + for (var f = 0; f < feature.length; f++) { + featureRule = feature[f]; + for (var t = 0; t < featureRule.required.tags.length; t++) { + tag = featureRule.required.tags[t]; + if (!_.has(editorRequiredTags, tag)) { + filterRule = new Drupal.FilterHTMLRule(); + filterRule.restrictedTags.tags = [tag]; + // @todo Neither Drupal.FilterHtmlRule nor + // Drupal.EditorFeatureHTMLRule allow for generic attribute + // value restrictions, only for the "class" and "style" + // attribute's values to be restricted. The filter_html filter + // always disallows the "style" attribute, so we only need to + // support "class" attribute value restrictions. Fix once + // https://www.drupal.org/node/2567801 lands. + filterRule.restrictedTags.allowed.attributes = featureRule.required.attributes.slice(0); + filterRule.restrictedTags.allowed.classes = featureRule.required.classes.slice(0); + editorRequiredTags[tag] = filterRule; + } + // The tag is already allowed, add any additionally allowed + // attributes. + else { + filterRule = editorRequiredTags[tag]; + filterRule.restrictedTags.allowed.attributes = _.union(filterRule.restrictedTags.allowed.attributes, featureRule.required.attributes); + filterRule.restrictedTags.allowed.classes = _.union(filterRule.restrictedTags.allowed.classes, featureRule.required.classes); + } + } + } + } + } + + // Now compare userAllowedTags with editorRequiredTags, and build + // autoAllowedTags, which contains: + // - any tags in editorRequiredTags but not in userAllowedTags (i.e. tags + // that are additionally going to be allowed) + // - any tags in editorRequiredTags that already exists in userAllowedTags + // but does not allow all attributes or attribute values + var autoAllowedTags = {}; + for (tag in editorRequiredTags) { + // If userAllowedTags does not contain a rule for this editor-required + // tag, then add it to the list of automatically allowed tags. + if (!_.has(userAllowedTags, tag)) { + autoAllowedTags[tag] = editorRequiredTags[tag]; + } + // Otherwise, if userAllowedTags already allows this tag, then check if + // additional attributes and classes on this tag are required by the + // editor. + else { + var requiredAttributes = editorRequiredTags[tag].restrictedTags.allowed.attributes; + var allowedAttributes = userAllowedTags[tag].restrictedTags.allowed.attributes; + var needsAdditionalAttributes = requiredAttributes.length && _.difference(requiredAttributes, allowedAttributes).length; + var requiredClasses = editorRequiredTags[tag].restrictedTags.allowed.classes; + var allowedClasses = userAllowedTags[tag].restrictedTags.allowed.classes; + var needsAdditionalClasses = requiredClasses.length && _.difference(requiredClasses, allowedClasses).length; + if (needsAdditionalAttributes || needsAdditionalClasses) { + autoAllowedTags[tag] = userAllowedTags[tag].clone(); + } + if (needsAdditionalAttributes) { + autoAllowedTags[tag].restrictedTags.allowed.attributes = _.union(allowedAttributes, requiredAttributes); + } + if (needsAdditionalClasses) { + autoAllowedTags[tag].restrictedTags.allowed.classes = _.union(allowedClasses, requiredClasses); + } + } + } + + return autoAllowedTags; + }, + + /** + * Parses the value of this.$allowedHTMLFormItem. + * + * @param {string} setting + * The string representation of the setting. For example: + * <p class="callout"> <br> <a href hreflang> + * + * @return {Object.<string, Drupal.FilterHTMLRule>} + * The corresponding text filter HTML rule objects, one per tag, keyed by + * tag name. + */ + _parseSetting: function (setting) { + var node; + var tag; + var rule; + var attributes; + var attribute; + var allowedTags = setting.match(/(<[^>]+>)/g); + var sandbox = document.createElement('div'); + var rules = {}; + for (var t = 0; t < allowedTags.length; t++) { + // Let the browser do the parsing work for us. + sandbox.innerHTML = allowedTags[t]; + node = sandbox.firstChild; + tag = node.tagName.toLowerCase(); + + // Build the Drupal.FilterHtmlRule object. + rule = new Drupal.FilterHTMLRule(); + // We create one rule per allowed tag, so always one tag. + rule.restrictedTags.tags = [tag]; + // Add the attribute restrictions. + attributes = node.attributes; + for (var i = 0; i < attributes.length; i++) { + attribute = attributes.item(i); + var attributeName = attribute.nodeName; + // @todo Drupal.FilterHtmlRule does not allow for generic attribute + // value restrictions, only for the "class" and "style" attribute's + // values. The filter_html filter always disallows the "style" + // attribute, so we only need to support "class" attribute value + // restrictions. Fix once https://www.drupal.org/node/2567801 lands. + if (attributeName === 'class') { + var attributeValue = attribute.textContent; + rule.restrictedTags.allowed.classes = attributeValue.split(' '); + } + else { + rule.restrictedTags.allowed.attributes.push(attributeName); + } + } + + rules[tag] = rule; + } + return rules; + }, + + /** + * Generates the value of this.$allowedHTMLFormItem. + * + * @param {Object.<string, Drupal.FilterHTMLRule>} tags + * The parsed representation of the setting. + * + * @return {Array} + * The string representation of the setting. e.g. "<p> <br> <a>" + */ + _generateSetting: function (tags) { + return _.reduce(tags, function (setting, rule, tag) { + if (setting.length) { + setting += ' '; + } + + setting += '<' + tag; + if (rule.restrictedTags.allowed.attributes.length) { + setting += ' ' + rule.restrictedTags.allowed.attributes.join(' '); + } + // @todo Drupal.FilterHtmlRule does not allow for generic attribute + // value restrictions, only for the "class" and "style" attribute's + // values. The filter_html filter always disallows the "style" + // attribute, so we only need to support "class" attribute value + // restrictions. Fix once https://www.drupal.org/node/2567801 lands. + if (rule.restrictedTags.allowed.classes.length) { + setting += ' class="' + rule.restrictedTags.allowed.classes.join(' ') + '"'; + } + + setting += '>'; + return setting; + }, ''); + } + + }; + + /** + * Theme function for the filter_html update message. + * + * @param {Array} tags + * An array of the new tags that are to be allowed. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.filterFilterHTMLUpdateMessage = function (tags) { + var html = ''; + var tagList = Drupal.behaviors.filterFilterHtmlUpdating._generateSetting(tags); + html += '<p class="editor-update-message">'; + html += Drupal.t('Based on the text editor configuration, these tags have automatically been added: <strong>@tag-list</strong>.', {'@tag-list': tagList}); + html += '</p>'; + return html; + }; + +})(jQuery, Drupal, _, document); diff --git a/core/modules/filter/filter.filter_html.admin.js b/core/modules/filter/filter.filter_html.admin.js index 7ddca90bba84..cd1235a3f121 100644 --- a/core/modules/filter/filter.filter_html.admin.js +++ b/core/modules/filter/filter.filter_html.admin.js @@ -1,32 +1,21 @@ /** - * @file - * Attaches behavior for updating filter_html's settings automatically. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/filter/filter.filter_html.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, _, document) { 'use strict'; if (Drupal.filterConfiguration) { - - /** - * Implement a live setting parser to prevent text editors from automatically - * enabling buttons that are not allowed by this filter's configuration. - * - * @namespace - */ Drupal.filterConfiguration.liveSettingParsers.filter_html = { - - /** - * @return {Array} - * An array of filter rules. - */ - getRules: function () { + getRules: function getRules() { var currentValue = $('#edit-filters-filter-html-settings-allowed-html').val(); var rules = Drupal.behaviors.filterFilterHtmlUpdating._parseSetting(currentValue); - // Build a FilterHTMLRule that reflects the hard-coded behavior that - // strips all "style" attribute and all "on*" attributes. var rule = new Drupal.FilterHTMLRule(); rule.restrictedTags.tags = ['*']; rule.restrictedTags.forbidden.attributes = ['style', 'on*']; @@ -37,116 +26,67 @@ }; } - /** - * Displays and updates what HTML tags are allowed to use in a filter. - * - * @type {Drupal~behavior} - * - * @todo Remove everything but 'attach' and 'detach' and make a proper object. - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior for updating allowed HTML tags. - */ Drupal.behaviors.filterFilterHtmlUpdating = { - - // The form item contains the "Allowed HTML tags" setting. $allowedHTMLFormItem: null, - // The description for the "Allowed HTML tags" field. $allowedHTMLDescription: null, - /** - * The parsed, user-entered tag list of $allowedHTMLFormItem - * - * @var {Object.<string, Drupal.FilterHTMLRule>} - */ userTags: {}, - // The auto-created tag list thus far added. autoTags: null, - // Track which new features have been added to the text editor. newFeatures: {}, - attach: function (context, settings) { + attach: function attach(context, settings) { var that = this; $(context).find('[name="filters[filter_html][settings][allowed_html]"]').once('filter-filter_html-updating').each(function () { that.$allowedHTMLFormItem = $(this); that.$allowedHTMLDescription = that.$allowedHTMLFormItem.closest('.js-form-item').find('.description'); that.userTags = that._parseSetting(this.value); - // Update the new allowed tags based on added text editor features. - $(document) - .on('drupalEditorFeatureAdded', function (e, feature) { + $(document).on('drupalEditorFeatureAdded', function (e, feature) { + that.newFeatures[feature.name] = feature.rules; + that._updateAllowedTags(); + }).on('drupalEditorFeatureModified', function (e, feature) { + if (that.newFeatures.hasOwnProperty(feature.name)) { that.newFeatures[feature.name] = feature.rules; that._updateAllowedTags(); - }) - .on('drupalEditorFeatureModified', function (e, feature) { - if (that.newFeatures.hasOwnProperty(feature.name)) { - that.newFeatures[feature.name] = feature.rules; - that._updateAllowedTags(); - } - }) - .on('drupalEditorFeatureRemoved', function (e, feature) { - if (that.newFeatures.hasOwnProperty(feature.name)) { - delete that.newFeatures[feature.name]; - that._updateAllowedTags(); - } - }); + } + }).on('drupalEditorFeatureRemoved', function (e, feature) { + if (that.newFeatures.hasOwnProperty(feature.name)) { + delete that.newFeatures[feature.name]; + that._updateAllowedTags(); + } + }); - // When the allowed tags list is manually changed, update userTags. that.$allowedHTMLFormItem.on('change.updateUserTags', function () { that.userTags = _.difference(that._parseSetting(this.value), that.autoTags); }); }); }, - /** - * Updates the "Allowed HTML tags" setting and shows an informative message. - */ - _updateAllowedTags: function () { - // Update the list of auto-created tags. + _updateAllowedTags: function _updateAllowedTags() { this.autoTags = this._calculateAutoAllowedTags(this.userTags, this.newFeatures); - // Remove any previous auto-created tag message. this.$allowedHTMLDescription.find('.editor-update-message').remove(); - // If any auto-created tags: insert message and update form item. if (!_.isEmpty(this.autoTags)) { this.$allowedHTMLDescription.append(Drupal.theme('filterFilterHTMLUpdateMessage', this.autoTags)); var userTagsWithoutOverrides = _.omit(this.userTags, _.keys(this.autoTags)); this.$allowedHTMLFormItem.val(this._generateSetting(userTagsWithoutOverrides) + ' ' + this._generateSetting(this.autoTags)); - } - // Restore to original state. - else { - this.$allowedHTMLFormItem.val(this._generateSetting(this.userTags)); - } + } else { + this.$allowedHTMLFormItem.val(this._generateSetting(this.userTags)); + } }, - /** - * Calculates which HTML tags the added text editor buttons need to work. - * - * The filter_html filter is only concerned with the required tags, not with - * any properties, nor with each feature's "allowed" tags. - * - * @param {Array} userAllowedTags - * The list of user-defined allowed tags. - * @param {object} newFeatures - * A list of {@link Drupal.EditorFeature} objects' rules, keyed by - * their name. - * - * @return {Array} - * A list of new allowed tags. - */ - _calculateAutoAllowedTags: function (userAllowedTags, newFeatures) { + _calculateAutoAllowedTags: function _calculateAutoAllowedTags(userAllowedTags, newFeatures) { var featureName; var feature; var featureRule; var filterRule; var tag; var editorRequiredTags = {}; - // Map the newly added Text Editor features to Drupal.FilterHtmlRule - // objects (to allow comparing userTags with autoTags). + for (featureName in newFeatures) { if (newFeatures.hasOwnProperty(featureName)) { feature = newFeatures[featureName]; @@ -157,79 +97,47 @@ if (!_.has(editorRequiredTags, tag)) { filterRule = new Drupal.FilterHTMLRule(); filterRule.restrictedTags.tags = [tag]; - // @todo Neither Drupal.FilterHtmlRule nor - // Drupal.EditorFeatureHTMLRule allow for generic attribute - // value restrictions, only for the "class" and "style" - // attribute's values to be restricted. The filter_html filter - // always disallows the "style" attribute, so we only need to - // support "class" attribute value restrictions. Fix once - // https://www.drupal.org/node/2567801 lands. + filterRule.restrictedTags.allowed.attributes = featureRule.required.attributes.slice(0); filterRule.restrictedTags.allowed.classes = featureRule.required.classes.slice(0); editorRequiredTags[tag] = filterRule; - } - // The tag is already allowed, add any additionally allowed - // attributes. - else { - filterRule = editorRequiredTags[tag]; - filterRule.restrictedTags.allowed.attributes = _.union(filterRule.restrictedTags.allowed.attributes, featureRule.required.attributes); - filterRule.restrictedTags.allowed.classes = _.union(filterRule.restrictedTags.allowed.classes, featureRule.required.classes); - } + } else { + filterRule = editorRequiredTags[tag]; + filterRule.restrictedTags.allowed.attributes = _.union(filterRule.restrictedTags.allowed.attributes, featureRule.required.attributes); + filterRule.restrictedTags.allowed.classes = _.union(filterRule.restrictedTags.allowed.classes, featureRule.required.classes); + } } } } } - // Now compare userAllowedTags with editorRequiredTags, and build - // autoAllowedTags, which contains: - // - any tags in editorRequiredTags but not in userAllowedTags (i.e. tags - // that are additionally going to be allowed) - // - any tags in editorRequiredTags that already exists in userAllowedTags - // but does not allow all attributes or attribute values var autoAllowedTags = {}; for (tag in editorRequiredTags) { - // If userAllowedTags does not contain a rule for this editor-required - // tag, then add it to the list of automatically allowed tags. if (!_.has(userAllowedTags, tag)) { autoAllowedTags[tag] = editorRequiredTags[tag]; - } - // Otherwise, if userAllowedTags already allows this tag, then check if - // additional attributes and classes on this tag are required by the - // editor. - else { - var requiredAttributes = editorRequiredTags[tag].restrictedTags.allowed.attributes; - var allowedAttributes = userAllowedTags[tag].restrictedTags.allowed.attributes; - var needsAdditionalAttributes = requiredAttributes.length && _.difference(requiredAttributes, allowedAttributes).length; - var requiredClasses = editorRequiredTags[tag].restrictedTags.allowed.classes; - var allowedClasses = userAllowedTags[tag].restrictedTags.allowed.classes; - var needsAdditionalClasses = requiredClasses.length && _.difference(requiredClasses, allowedClasses).length; - if (needsAdditionalAttributes || needsAdditionalClasses) { - autoAllowedTags[tag] = userAllowedTags[tag].clone(); - } - if (needsAdditionalAttributes) { - autoAllowedTags[tag].restrictedTags.allowed.attributes = _.union(allowedAttributes, requiredAttributes); - } - if (needsAdditionalClasses) { - autoAllowedTags[tag].restrictedTags.allowed.classes = _.union(allowedClasses, requiredClasses); + } else { + var requiredAttributes = editorRequiredTags[tag].restrictedTags.allowed.attributes; + var allowedAttributes = userAllowedTags[tag].restrictedTags.allowed.attributes; + var needsAdditionalAttributes = requiredAttributes.length && _.difference(requiredAttributes, allowedAttributes).length; + var requiredClasses = editorRequiredTags[tag].restrictedTags.allowed.classes; + var allowedClasses = userAllowedTags[tag].restrictedTags.allowed.classes; + var needsAdditionalClasses = requiredClasses.length && _.difference(requiredClasses, allowedClasses).length; + if (needsAdditionalAttributes || needsAdditionalClasses) { + autoAllowedTags[tag] = userAllowedTags[tag].clone(); + } + if (needsAdditionalAttributes) { + autoAllowedTags[tag].restrictedTags.allowed.attributes = _.union(allowedAttributes, requiredAttributes); + } + if (needsAdditionalClasses) { + autoAllowedTags[tag].restrictedTags.allowed.classes = _.union(allowedClasses, requiredClasses); + } } - } } return autoAllowedTags; }, - /** - * Parses the value of this.$allowedHTMLFormItem. - * - * @param {string} setting - * The string representation of the setting. For example: - * <p class="callout"> <br> <a href hreflang> - * - * @return {Object.<string, Drupal.FilterHTMLRule>} - * The corresponding text filter HTML rule objects, one per tag, keyed by - * tag name. - */ - _parseSetting: function (setting) { + _parseSetting: function _parseSetting(setting) { var node; var tag; var rule; @@ -239,30 +147,23 @@ var sandbox = document.createElement('div'); var rules = {}; for (var t = 0; t < allowedTags.length; t++) { - // Let the browser do the parsing work for us. sandbox.innerHTML = allowedTags[t]; node = sandbox.firstChild; tag = node.tagName.toLowerCase(); - // Build the Drupal.FilterHtmlRule object. rule = new Drupal.FilterHTMLRule(); - // We create one rule per allowed tag, so always one tag. + rule.restrictedTags.tags = [tag]; - // Add the attribute restrictions. + attributes = node.attributes; for (var i = 0; i < attributes.length; i++) { attribute = attributes.item(i); var attributeName = attribute.nodeName; - // @todo Drupal.FilterHtmlRule does not allow for generic attribute - // value restrictions, only for the "class" and "style" attribute's - // values. The filter_html filter always disallows the "style" - // attribute, so we only need to support "class" attribute value - // restrictions. Fix once https://www.drupal.org/node/2567801 lands. + if (attributeName === 'class') { var attributeValue = attribute.textContent; rule.restrictedTags.allowed.classes = attributeValue.split(' '); - } - else { + } else { rule.restrictedTags.allowed.attributes.push(attributeName); } } @@ -272,16 +173,7 @@ return rules; }, - /** - * Generates the value of this.$allowedHTMLFormItem. - * - * @param {Object.<string, Drupal.FilterHTMLRule>} tags - * The parsed representation of the setting. - * - * @return {Array} - * The string representation of the setting. e.g. "<p> <br> <a>" - */ - _generateSetting: function (tags) { + _generateSetting: function _generateSetting(tags) { return _.reduce(tags, function (setting, rule, tag) { if (setting.length) { setting += ' '; @@ -291,11 +183,7 @@ if (rule.restrictedTags.allowed.attributes.length) { setting += ' ' + rule.restrictedTags.allowed.attributes.join(' '); } - // @todo Drupal.FilterHtmlRule does not allow for generic attribute - // value restrictions, only for the "class" and "style" attribute's - // values. The filter_html filter always disallows the "style" - // attribute, so we only need to support "class" attribute value - // restrictions. Fix once https://www.drupal.org/node/2567801 lands. + if (rule.restrictedTags.allowed.classes.length) { setting += ' class="' + rule.restrictedTags.allowed.classes.join(' ') + '"'; } @@ -307,22 +195,12 @@ }; - /** - * Theme function for the filter_html update message. - * - * @param {Array} tags - * An array of the new tags that are to be allowed. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.filterFilterHTMLUpdateMessage = function (tags) { var html = ''; var tagList = Drupal.behaviors.filterFilterHtmlUpdating._generateSetting(tags); html += '<p class="editor-update-message">'; - html += Drupal.t('Based on the text editor configuration, these tags have automatically been added: <strong>@tag-list</strong>.', {'@tag-list': tagList}); + html += Drupal.t('Based on the text editor configuration, these tags have automatically been added: <strong>@tag-list</strong>.', { '@tag-list': tagList }); html += '</p>'; return html; }; - -})(jQuery, Drupal, _, document); +})(jQuery, Drupal, _, document); \ No newline at end of file diff --git a/core/modules/filter/filter.js b/core/modules/filter/filter.js index b79104733edc..2598bde610e9 100644 --- a/core/modules/filter/filter.js +++ b/core/modules/filter/filter.js @@ -1,39 +1,25 @@ /** - * @file - * Attaches behavior for the Filter module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/filter/filter.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Displays the guidelines of the selected text format automatically. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior for updating filter guidelines. - */ Drupal.behaviors.filterGuidelines = { - attach: function (context) { + attach: function attach(context) { function updateFilterGuidelines(event) { var $this = $(event.target); var value = $this.val(); - $this.closest('.filter-wrapper') - .find('.filter-guidelines-item').hide() - .filter('.filter-guidelines-' + value).show(); + $this.closest('.filter-wrapper').find('.filter-guidelines-item').hide().filter('.filter-guidelines-' + value).show(); } - $(context).find('.filter-guidelines').once('filter-guidelines') - .find(':header').hide() - .closest('.filter-wrapper').find('select.filter-list') - .on('change.filterGuidelines', updateFilterGuidelines) - // Need to trigger the namespaced event to avoid triggering formUpdated - // when initializing the select. - .trigger('change.filterGuidelines'); + $(context).find('.filter-guidelines').once('filter-guidelines').find(':header').hide().closest('.filter-wrapper').find('select.filter-list').on('change.filterGuidelines', updateFilterGuidelines).trigger('change.filterGuidelines'); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/history/js/history.es6.js b/core/modules/history/js/history.es6.js new file mode 100644 index 000000000000..6ce807c074d1 --- /dev/null +++ b/core/modules/history/js/history.es6.js @@ -0,0 +1,134 @@ +/** + * @file + * JavaScript API for the History module, with client-side caching. + * + * May only be loaded for authenticated users, with the History module enabled. + */ + +(function ($, Drupal, drupalSettings, storage) { + + 'use strict'; + + var currentUserID = parseInt(drupalSettings.user.uid, 10); + + // Any comment that is older than 30 days is automatically considered read, + // so for these we don't need to perform a request at all! + var thirtyDaysAgo = Math.round(new Date().getTime() / 1000) - 30 * 24 * 60 * 60; + + // Use the data embedded in the page, if available. + var embeddedLastReadTimestamps = false; + if (drupalSettings.history && drupalSettings.history.lastReadTimestamps) { + embeddedLastReadTimestamps = drupalSettings.history.lastReadTimestamps; + } + + /** + * @namespace + */ + Drupal.history = { + + /** + * Fetch "last read" timestamps for the given nodes. + * + * @param {Array} nodeIDs + * An array of node IDs. + * @param {function} callback + * A callback that is called after the requested timestamps were fetched. + */ + fetchTimestamps: function (nodeIDs, callback) { + // Use the data embedded in the page, if available. + if (embeddedLastReadTimestamps) { + callback(); + return; + } + + $.ajax({ + url: Drupal.url('history/get_node_read_timestamps'), + type: 'POST', + data: {'node_ids[]': nodeIDs}, + dataType: 'json', + success: function (results) { + for (var nodeID in results) { + if (results.hasOwnProperty(nodeID)) { + storage.setItem('Drupal.history.' + currentUserID + '.' + nodeID, results[nodeID]); + } + } + callback(); + } + }); + }, + + /** + * Get the last read timestamp for the given node. + * + * @param {number|string} nodeID + * A node ID. + * + * @return {number} + * A UNIX timestamp. + */ + getLastRead: function (nodeID) { + // Use the data embedded in the page, if available. + if (embeddedLastReadTimestamps && embeddedLastReadTimestamps[nodeID]) { + return parseInt(embeddedLastReadTimestamps[nodeID], 10); + } + return parseInt(storage.getItem('Drupal.history.' + currentUserID + '.' + nodeID) || 0, 10); + }, + + /** + * Marks a node as read, store the last read timestamp client-side. + * + * @param {number|string} nodeID + * A node ID. + */ + markAsRead: function (nodeID) { + $.ajax({ + url: Drupal.url('history/' + nodeID + '/read'), + type: 'POST', + dataType: 'json', + success: function (timestamp) { + // If the data is embedded in the page, don't store on the client + // side. + if (embeddedLastReadTimestamps && embeddedLastReadTimestamps[nodeID]) { + return; + } + + storage.setItem('Drupal.history.' + currentUserID + '.' + nodeID, timestamp); + } + }); + }, + + /** + * Determines whether a server check is necessary. + * + * Any content that is >30 days old never gets a "new" or "updated" + * indicator. Any content that was published before the oldest known reading + * also never gets a "new" or "updated" indicator, because it must've been + * read already. + * + * @param {number|string} nodeID + * A node ID. + * @param {number} contentTimestamp + * The time at which some content (e.g. a comment) was published. + * + * @return {bool} + * Whether a server check is necessary for the given node and its + * timestamp. + */ + needsServerCheck: function (nodeID, contentTimestamp) { + // First check if the content is older than 30 days, then we can bail + // early. + if (contentTimestamp < thirtyDaysAgo) { + return false; + } + + // Use the data embedded in the page, if available. + if (embeddedLastReadTimestamps && embeddedLastReadTimestamps[nodeID]) { + return contentTimestamp > parseInt(embeddedLastReadTimestamps[nodeID], 10); + } + + var minLastReadTimestamp = parseInt(storage.getItem('Drupal.history.' + currentUserID + '.' + nodeID) || 0, 10); + return contentTimestamp > minLastReadTimestamp; + } + }; + +})(jQuery, Drupal, drupalSettings, window.localStorage); diff --git a/core/modules/history/js/history.js b/core/modules/history/js/history.js index 6ce807c074d1..c24f4e839ae7 100644 --- a/core/modules/history/js/history.js +++ b/core/modules/history/js/history.js @@ -1,9 +1,10 @@ /** - * @file - * JavaScript API for the History module, with client-side caching. - * - * May only be loaded for authenticated users, with the History module enabled. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/history/js/history.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings, storage) { @@ -11,31 +12,15 @@ var currentUserID = parseInt(drupalSettings.user.uid, 10); - // Any comment that is older than 30 days is automatically considered read, - // so for these we don't need to perform a request at all! var thirtyDaysAgo = Math.round(new Date().getTime() / 1000) - 30 * 24 * 60 * 60; - // Use the data embedded in the page, if available. var embeddedLastReadTimestamps = false; if (drupalSettings.history && drupalSettings.history.lastReadTimestamps) { embeddedLastReadTimestamps = drupalSettings.history.lastReadTimestamps; } - /** - * @namespace - */ Drupal.history = { - - /** - * Fetch "last read" timestamps for the given nodes. - * - * @param {Array} nodeIDs - * An array of node IDs. - * @param {function} callback - * A callback that is called after the requested timestamps were fetched. - */ - fetchTimestamps: function (nodeIDs, callback) { - // Use the data embedded in the page, if available. + fetchTimestamps: function fetchTimestamps(nodeIDs, callback) { if (embeddedLastReadTimestamps) { callback(); return; @@ -44,9 +29,9 @@ $.ajax({ url: Drupal.url('history/get_node_read_timestamps'), type: 'POST', - data: {'node_ids[]': nodeIDs}, + data: { 'node_ids[]': nodeIDs }, dataType: 'json', - success: function (results) { + success: function success(results) { for (var nodeID in results) { if (results.hasOwnProperty(nodeID)) { storage.setItem('Drupal.history.' + currentUserID + '.' + nodeID, results[nodeID]); @@ -57,37 +42,19 @@ }); }, - /** - * Get the last read timestamp for the given node. - * - * @param {number|string} nodeID - * A node ID. - * - * @return {number} - * A UNIX timestamp. - */ - getLastRead: function (nodeID) { - // Use the data embedded in the page, if available. + getLastRead: function getLastRead(nodeID) { if (embeddedLastReadTimestamps && embeddedLastReadTimestamps[nodeID]) { return parseInt(embeddedLastReadTimestamps[nodeID], 10); } return parseInt(storage.getItem('Drupal.history.' + currentUserID + '.' + nodeID) || 0, 10); }, - /** - * Marks a node as read, store the last read timestamp client-side. - * - * @param {number|string} nodeID - * A node ID. - */ - markAsRead: function (nodeID) { + markAsRead: function markAsRead(nodeID) { $.ajax({ url: Drupal.url('history/' + nodeID + '/read'), type: 'POST', dataType: 'json', - success: function (timestamp) { - // If the data is embedded in the page, don't store on the client - // side. + success: function success(timestamp) { if (embeddedLastReadTimestamps && embeddedLastReadTimestamps[nodeID]) { return; } @@ -97,31 +64,11 @@ }); }, - /** - * Determines whether a server check is necessary. - * - * Any content that is >30 days old never gets a "new" or "updated" - * indicator. Any content that was published before the oldest known reading - * also never gets a "new" or "updated" indicator, because it must've been - * read already. - * - * @param {number|string} nodeID - * A node ID. - * @param {number} contentTimestamp - * The time at which some content (e.g. a comment) was published. - * - * @return {bool} - * Whether a server check is necessary for the given node and its - * timestamp. - */ - needsServerCheck: function (nodeID, contentTimestamp) { - // First check if the content is older than 30 days, then we can bail - // early. + needsServerCheck: function needsServerCheck(nodeID, contentTimestamp) { if (contentTimestamp < thirtyDaysAgo) { return false; } - // Use the data embedded in the page, if available. if (embeddedLastReadTimestamps && embeddedLastReadTimestamps[nodeID]) { return contentTimestamp > parseInt(embeddedLastReadTimestamps[nodeID], 10); } @@ -130,5 +77,4 @@ return contentTimestamp > minLastReadTimestamp; } }; - -})(jQuery, Drupal, drupalSettings, window.localStorage); +})(jQuery, Drupal, drupalSettings, window.localStorage); \ No newline at end of file diff --git a/core/modules/history/js/mark-as-read.es6.js b/core/modules/history/js/mark-as-read.es6.js new file mode 100644 index 000000000000..e225401e33ba --- /dev/null +++ b/core/modules/history/js/mark-as-read.es6.js @@ -0,0 +1,23 @@ +/** + * @file + * Marks the nodes listed in drupalSettings.history.nodesToMarkAsRead as read. + * + * Uses the History module JavaScript API. + * + * @see Drupal.history + */ + +(function (window, Drupal, drupalSettings) { + + 'use strict'; + + // When the window's "load" event is triggered, mark all enumerated nodes as + // read. This still allows for Drupal behaviors (which are triggered on the + // "DOMContentReady" event) to add "new" and "updated" indicators. + window.addEventListener('load', function () { + if (drupalSettings.history && drupalSettings.history.nodesToMarkAsRead) { + Object.keys(drupalSettings.history.nodesToMarkAsRead).forEach(Drupal.history.markAsRead); + } + }); + +})(window, Drupal, drupalSettings); diff --git a/core/modules/history/js/mark-as-read.js b/core/modules/history/js/mark-as-read.js index e225401e33ba..bd8f8f0fb982 100644 --- a/core/modules/history/js/mark-as-read.js +++ b/core/modules/history/js/mark-as-read.js @@ -1,23 +1,18 @@ /** - * @file - * Marks the nodes listed in drupalSettings.history.nodesToMarkAsRead as read. - * - * Uses the History module JavaScript API. - * - * @see Drupal.history - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/history/js/mark-as-read.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (window, Drupal, drupalSettings) { 'use strict'; - // When the window's "load" event is triggered, mark all enumerated nodes as - // read. This still allows for Drupal behaviors (which are triggered on the - // "DOMContentReady" event) to add "new" and "updated" indicators. window.addEventListener('load', function () { if (drupalSettings.history && drupalSettings.history.nodesToMarkAsRead) { Object.keys(drupalSettings.history.nodesToMarkAsRead).forEach(Drupal.history.markAsRead); } }); - -})(window, Drupal, drupalSettings); +})(window, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/image/js/editors/image.es6.js b/core/modules/image/js/editors/image.es6.js new file mode 100644 index 000000000000..dea08df59ee8 --- /dev/null +++ b/core/modules/image/js/editors/image.es6.js @@ -0,0 +1,342 @@ +/** + * @file + * Drag+drop based in-place editor for images. + */ + +(function ($, _, Drupal) { + + 'use strict'; + + Drupal.quickedit.editors.image = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.image# */{ + + /** + * @constructs + * + * @augments Drupal.quickedit.EditorView + * + * @param {object} options + * Options for the image editor. + */ + initialize: function (options) { + Drupal.quickedit.EditorView.prototype.initialize.call(this, options); + // Set our original value to our current HTML (for reverting). + this.model.set('originalValue', this.$el.html().trim()); + // $.val() callback function for copying input from our custom form to + // the Quick Edit Field Form. + this.model.set('currentValue', function (index, value) { + var matches = $(this).attr('name').match(/(alt|title)]$/); + if (matches) { + var name = matches[1]; + var $toolgroup = $('#' + options.fieldModel.toolbarView.getMainWysiwygToolgroupId()); + var $input = $toolgroup.find('.quickedit-image-field-info input[name="' + name + '"]'); + if ($input.length) { + return $input.val(); + } + } + }); + }, + + /** + * @inheritdoc + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The field model that holds the state. + * @param {string} state + * The state to change to. + * @param {object} options + * State options, if needed by the state change. + */ + stateChange: function (fieldModel, state, options) { + var from = fieldModel.previous('state'); + switch (state) { + case 'inactive': + break; + + case 'candidate': + if (from !== 'inactive') { + this.$el.find('.quickedit-image-dropzone').remove(); + this.$el.removeClass('quickedit-image-element'); + } + if (from === 'invalid') { + this.removeValidationErrors(); + } + break; + + case 'highlighted': + break; + + case 'activating': + // Defer updating the field model until the current state change has + // propagated, to not trigger a nested state change event. + _.defer(function () { + fieldModel.set('state', 'active'); + }); + break; + + case 'active': + var self = this; + + // Indicate that this element is being edited by Quick Edit Image. + this.$el.addClass('quickedit-image-element'); + + // Render our initial dropzone element. Once the user reverts changes + // or saves a new image, this element is removed. + var $dropzone = this.renderDropzone('upload', Drupal.t('Drop file here or click to upload')); + + $dropzone.on('dragenter', function (e) { + $(this).addClass('hover'); + }); + $dropzone.on('dragleave', function (e) { + $(this).removeClass('hover'); + }); + + $dropzone.on('drop', function (e) { + // Only respond when a file is dropped (could be another element). + if (e.originalEvent.dataTransfer && e.originalEvent.dataTransfer.files.length) { + $(this).removeClass('hover'); + self.uploadImage(e.originalEvent.dataTransfer.files[0]); + } + }); + + $dropzone.on('click', function (e) { + // Create an <input> element without appending it to the DOM, and + // trigger a click event. This is the easiest way to arbitrarily + // open the browser's upload dialog. + $('<input type="file">') + .trigger('click') + .on('change', function () { + if (this.files.length) { + self.uploadImage(this.files[0]); + } + }); + }); + + // Prevent the browser's default behavior when dragging files onto + // the document (usually opens them in the same tab). + $dropzone.on('dragover dragenter dragleave drop click', function (e) { + e.preventDefault(); + e.stopPropagation(); + }); + + this.renderToolbar(fieldModel); + break; + + case 'changed': + break; + + case 'saving': + if (from === 'invalid') { + this.removeValidationErrors(); + } + + this.save(options); + break; + + case 'saved': + break; + + case 'invalid': + this.showValidationErrors(); + break; + } + }, + + /** + * Validates/uploads a given file. + * + * @param {File} file + * The file to upload. + */ + uploadImage: function (file) { + // Indicate loading by adding a special class to our icon. + this.renderDropzone('upload loading', Drupal.t('Uploading <i>@file</i>…', {'@file': file.name})); + + // Build a valid URL for our endpoint. + var fieldID = this.fieldModel.get('fieldID'); + var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/upload/!entity_type/!id/!field_name/!langcode/!view_mode')); + + // Construct form data that our endpoint can consume. + var data = new FormData(); + data.append('files[image]', file); + + // Construct a POST request to our endpoint. + var self = this; + this.ajax({ + type: 'POST', + url: url, + data: data, + success: function (response) { + var $el = $(self.fieldModel.get('el')); + // Indicate that the field has changed - this enables the + // "Save" button. + self.fieldModel.set('state', 'changed'); + self.fieldModel.get('entity').set('inTempStore', true); + self.removeValidationErrors(); + + // Replace our html with the new image. If we replaced our entire + // element with data.html, we would have to implement complicated logic + // like what's in Drupal.quickedit.AppView.renderUpdatedField. + var $content = $(response.html).closest('[data-quickedit-field-id]').children(); + $el.empty().append($content); + } + }); + }, + + /** + * Utility function to make an AJAX request to the server. + * + * In addition to formatting the correct request, this also handles error + * codes and messages by displaying them visually inline with the image. + * + * Drupal.ajax is not called here as the Form API is unused by this + * in-place editor, and our JSON requests/responses try to be + * editor-agnostic. Ideally similar logic and routes could be used by + * modules like CKEditor for drag+drop file uploads as well. + * + * @param {object} options + * Ajax options. + * @param {string} options.type + * The type of request (i.e. GET, POST, PUT, DELETE, etc.) + * @param {string} options.url + * The URL for the request. + * @param {*} options.data + * The data to send to the server. + * @param {function} options.success + * A callback function used when a request is successful, without errors. + */ + ajax: function (options) { + var defaultOptions = { + context: this, + dataType: 'json', + cache: false, + contentType: false, + processData: false, + error: function () { + this.renderDropzone('error', Drupal.t('A server error has occurred.')); + } + }; + + var ajaxOptions = $.extend(defaultOptions, options); + var successCallback = ajaxOptions.success; + + // Handle the success callback. + ajaxOptions.success = function (response) { + if (response.main_error) { + this.renderDropzone('error', response.main_error); + if (response.errors.length) { + this.model.set('validationErrors', response.errors); + } + this.showValidationErrors(); + } + else { + successCallback(response); + } + }; + + $.ajax(ajaxOptions); + }, + + /** + * Renders our toolbar form for editing metadata. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The current Field Model. + */ + renderToolbar: function (fieldModel) { + var $toolgroup = $('#' + fieldModel.toolbarView.getMainWysiwygToolgroupId()); + var $toolbar = $toolgroup.find('.quickedit-image-field-info'); + if ($toolbar.length === 0) { + // Perform an AJAX request for extra image info (alt/title). + var fieldID = fieldModel.get('fieldID'); + var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/info/!entity_type/!id/!field_name/!langcode/!view_mode')); + var self = this; + self.ajax({ + type: 'GET', + url: url, + success: function (response) { + $toolbar = $(Drupal.theme.quickeditImageToolbar(response)); + $toolgroup.append($toolbar); + $toolbar.on('keyup paste', function () { + fieldModel.set('state', 'changed'); + }); + // Re-position the toolbar, which could have changed size. + fieldModel.get('entity').toolbarView.position(); + } + }); + } + }, + + /** + * Renders our dropzone element. + * + * @param {string} state + * The current state of our editor. Only used for visual styling. + * @param {string} text + * The text to display in the dropzone area. + * + * @return {jQuery} + * The rendered dropzone. + */ + renderDropzone: function (state, text) { + var $dropzone = this.$el.find('.quickedit-image-dropzone'); + // If the element already exists, modify its contents. + if ($dropzone.length) { + $dropzone + .removeClass('upload error hover loading') + .addClass('.quickedit-image-dropzone ' + state) + .children('.quickedit-image-text') + .html(text); + } + else { + $dropzone = $(Drupal.theme('quickeditImageDropzone', { + state: state, + text: text + })); + this.$el.append($dropzone); + } + + return $dropzone; + }, + + /** + * @inheritdoc + */ + revert: function () { + this.$el.html(this.model.get('originalValue')); + }, + + /** + * @inheritdoc + */ + getQuickEditUISettings: function () { + return {padding: false, unifiedToolbar: true, fullWidthToolbar: true, popup: false}; + }, + + /** + * @inheritdoc + */ + showValidationErrors: function () { + var errors = Drupal.theme('quickeditImageErrors', { + errors: this.model.get('validationErrors') + }); + $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId()) + .append(errors); + this.getEditedElement() + .addClass('quickedit-validation-error'); + // Re-position the toolbar, which could have changed size. + this.fieldModel.get('entity').toolbarView.position(); + }, + + /** + * @inheritdoc + */ + removeValidationErrors: function () { + $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId()) + .find('.quickedit-image-errors').remove(); + this.getEditedElement() + .removeClass('quickedit-validation-error'); + } + + }); + +})(jQuery, _, Drupal); diff --git a/core/modules/image/js/editors/image.js b/core/modules/image/js/editors/image.js index dea08df59ee8..635cbaf5469d 100644 --- a/core/modules/image/js/editors/image.js +++ b/core/modules/image/js/editors/image.js @@ -1,28 +1,21 @@ /** - * @file - * Drag+drop based in-place editor for images. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/image/js/editors/image.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, _, Drupal) { 'use strict'; - Drupal.quickedit.editors.image = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.image# */{ - - /** - * @constructs - * - * @augments Drupal.quickedit.EditorView - * - * @param {object} options - * Options for the image editor. - */ - initialize: function (options) { + Drupal.quickedit.editors.image = Drupal.quickedit.EditorView.extend({ + initialize: function initialize(options) { Drupal.quickedit.EditorView.prototype.initialize.call(this, options); - // Set our original value to our current HTML (for reverting). + this.model.set('originalValue', this.$el.html().trim()); - // $.val() callback function for copying input from our custom form to - // the Quick Edit Field Form. + this.model.set('currentValue', function (index, value) { var matches = $(this).attr('name').match(/(alt|title)]$/); if (matches) { @@ -36,17 +29,7 @@ }); }, - /** - * @inheritdoc - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * The field model that holds the state. - * @param {string} state - * The state to change to. - * @param {object} options - * State options, if needed by the state change. - */ - stateChange: function (fieldModel, state, options) { + stateChange: function stateChange(fieldModel, state, options) { var from = fieldModel.previous('state'); switch (state) { case 'inactive': @@ -66,8 +49,6 @@ break; case 'activating': - // Defer updating the field model until the current state change has - // propagated, to not trigger a nested state change event. _.defer(function () { fieldModel.set('state', 'active'); }); @@ -76,11 +57,8 @@ case 'active': var self = this; - // Indicate that this element is being edited by Quick Edit Image. this.$el.addClass('quickedit-image-element'); - // Render our initial dropzone element. Once the user reverts changes - // or saves a new image, this element is removed. var $dropzone = this.renderDropzone('upload', Drupal.t('Drop file here or click to upload')); $dropzone.on('dragenter', function (e) { @@ -91,7 +69,6 @@ }); $dropzone.on('drop', function (e) { - // Only respond when a file is dropped (could be another element). if (e.originalEvent.dataTransfer && e.originalEvent.dataTransfer.files.length) { $(this).removeClass('hover'); self.uploadImage(e.originalEvent.dataTransfer.files[0]); @@ -99,20 +76,13 @@ }); $dropzone.on('click', function (e) { - // Create an <input> element without appending it to the DOM, and - // trigger a click event. This is the easiest way to arbitrarily - // open the browser's upload dialog. - $('<input type="file">') - .trigger('click') - .on('change', function () { - if (this.files.length) { - self.uploadImage(this.files[0]); - } - }); + $('<input type="file">').trigger('click').on('change', function () { + if (this.files.length) { + self.uploadImage(this.files[0]); + } + }); }); - // Prevent the browser's default behavior when dragging files onto - // the document (usually opens them in the same tab). $dropzone.on('dragover dragenter dragleave drop click', function (e) { e.preventDefault(); e.stopPropagation(); @@ -141,77 +111,41 @@ } }, - /** - * Validates/uploads a given file. - * - * @param {File} file - * The file to upload. - */ - uploadImage: function (file) { - // Indicate loading by adding a special class to our icon. - this.renderDropzone('upload loading', Drupal.t('Uploading <i>@file</i>…', {'@file': file.name})); - - // Build a valid URL for our endpoint. + uploadImage: function uploadImage(file) { + this.renderDropzone('upload loading', Drupal.t('Uploading <i>@file</i>…', { '@file': file.name })); + var fieldID = this.fieldModel.get('fieldID'); var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/upload/!entity_type/!id/!field_name/!langcode/!view_mode')); - // Construct form data that our endpoint can consume. var data = new FormData(); data.append('files[image]', file); - // Construct a POST request to our endpoint. var self = this; this.ajax({ type: 'POST', url: url, data: data, - success: function (response) { + success: function success(response) { var $el = $(self.fieldModel.get('el')); - // Indicate that the field has changed - this enables the - // "Save" button. + self.fieldModel.set('state', 'changed'); self.fieldModel.get('entity').set('inTempStore', true); self.removeValidationErrors(); - // Replace our html with the new image. If we replaced our entire - // element with data.html, we would have to implement complicated logic - // like what's in Drupal.quickedit.AppView.renderUpdatedField. var $content = $(response.html).closest('[data-quickedit-field-id]').children(); $el.empty().append($content); } }); }, - /** - * Utility function to make an AJAX request to the server. - * - * In addition to formatting the correct request, this also handles error - * codes and messages by displaying them visually inline with the image. - * - * Drupal.ajax is not called here as the Form API is unused by this - * in-place editor, and our JSON requests/responses try to be - * editor-agnostic. Ideally similar logic and routes could be used by - * modules like CKEditor for drag+drop file uploads as well. - * - * @param {object} options - * Ajax options. - * @param {string} options.type - * The type of request (i.e. GET, POST, PUT, DELETE, etc.) - * @param {string} options.url - * The URL for the request. - * @param {*} options.data - * The data to send to the server. - * @param {function} options.success - * A callback function used when a request is successful, without errors. - */ - ajax: function (options) { + ajax: function ajax(options) { var defaultOptions = { context: this, dataType: 'json', cache: false, contentType: false, processData: false, - error: function () { + error: function error() { this.renderDropzone('error', Drupal.t('A server error has occurred.')); } }; @@ -219,7 +153,6 @@ var ajaxOptions = $.extend(defaultOptions, options); var successCallback = ajaxOptions.success; - // Handle the success callback. ajaxOptions.success = function (response) { if (response.main_error) { this.renderDropzone('error', response.main_error); @@ -227,8 +160,7 @@ this.model.set('validationErrors', response.errors); } this.showValidationErrors(); - } - else { + } else { successCallback(response); } }; @@ -236,58 +168,35 @@ $.ajax(ajaxOptions); }, - /** - * Renders our toolbar form for editing metadata. - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * The current Field Model. - */ - renderToolbar: function (fieldModel) { + renderToolbar: function renderToolbar(fieldModel) { var $toolgroup = $('#' + fieldModel.toolbarView.getMainWysiwygToolgroupId()); var $toolbar = $toolgroup.find('.quickedit-image-field-info'); if ($toolbar.length === 0) { - // Perform an AJAX request for extra image info (alt/title). var fieldID = fieldModel.get('fieldID'); var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/info/!entity_type/!id/!field_name/!langcode/!view_mode')); var self = this; self.ajax({ type: 'GET', url: url, - success: function (response) { + success: function success(response) { $toolbar = $(Drupal.theme.quickeditImageToolbar(response)); $toolgroup.append($toolbar); $toolbar.on('keyup paste', function () { fieldModel.set('state', 'changed'); }); - // Re-position the toolbar, which could have changed size. + fieldModel.get('entity').toolbarView.position(); } }); } }, - /** - * Renders our dropzone element. - * - * @param {string} state - * The current state of our editor. Only used for visual styling. - * @param {string} text - * The text to display in the dropzone area. - * - * @return {jQuery} - * The rendered dropzone. - */ - renderDropzone: function (state, text) { + renderDropzone: function renderDropzone(state, text) { var $dropzone = this.$el.find('.quickedit-image-dropzone'); - // If the element already exists, modify its contents. + if ($dropzone.length) { - $dropzone - .removeClass('upload error hover loading') - .addClass('.quickedit-image-dropzone ' + state) - .children('.quickedit-image-text') - .html(text); - } - else { + $dropzone.removeClass('upload error hover loading').addClass('.quickedit-image-dropzone ' + state).children('.quickedit-image-text').html(text); + } else { $dropzone = $(Drupal.theme('quickeditImageDropzone', { state: state, text: text @@ -298,45 +207,28 @@ return $dropzone; }, - /** - * @inheritdoc - */ - revert: function () { + revert: function revert() { this.$el.html(this.model.get('originalValue')); }, - /** - * @inheritdoc - */ - getQuickEditUISettings: function () { - return {padding: false, unifiedToolbar: true, fullWidthToolbar: true, popup: false}; + getQuickEditUISettings: function getQuickEditUISettings() { + return { padding: false, unifiedToolbar: true, fullWidthToolbar: true, popup: false }; }, - /** - * @inheritdoc - */ - showValidationErrors: function () { + showValidationErrors: function showValidationErrors() { var errors = Drupal.theme('quickeditImageErrors', { errors: this.model.get('validationErrors') }); - $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId()) - .append(errors); - this.getEditedElement() - .addClass('quickedit-validation-error'); - // Re-position the toolbar, which could have changed size. + $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId()).append(errors); + this.getEditedElement().addClass('quickedit-validation-error'); + this.fieldModel.get('entity').toolbarView.position(); }, - /** - * @inheritdoc - */ - removeValidationErrors: function () { - $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId()) - .find('.quickedit-image-errors').remove(); - this.getEditedElement() - .removeClass('quickedit-validation-error'); + removeValidationErrors: function removeValidationErrors() { + $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId()).find('.quickedit-image-errors').remove(); + this.getEditedElement().removeClass('quickedit-validation-error'); } }); - -})(jQuery, _, Drupal); +})(jQuery, _, Drupal); \ No newline at end of file diff --git a/core/modules/image/js/theme.es6.js b/core/modules/image/js/theme.es6.js new file mode 100644 index 000000000000..cba8f7bbca6b --- /dev/null +++ b/core/modules/image/js/theme.es6.js @@ -0,0 +1,86 @@ +/** + * @file + * Provides theme functions for image Quick Edit's client-side HTML. + */ + +(function (Drupal) { + + 'use strict'; + + /** + * Theme function for validation errors of the Image in-place editor. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {string} settings.errors + * Already escaped HTML representing error messages. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditImageErrors = function (settings) { + return '<div class="quickedit-image-errors">' + settings.errors + '</div>'; + }; + + /** + * Theme function for the dropzone element of the Image module's in-place + * editor. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {string} settings.state + * State of the upload. + * @param {string} settings.text + * Text to display inline with the dropzone element. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditImageDropzone = function (settings) { + return '<div class="quickedit-image-dropzone ' + settings.state + '">' + + ' <i class="quickedit-image-icon"></i>' + + ' <span class="quickedit-image-text">' + settings.text + '</span>' + + '</div>'; + }; + + /** + * Theme function for the toolbar of the Image module's in-place editor. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {bool} settings.alt_field + * Whether or not the "Alt" field is enabled for this field. + * @param {bool} settings.alt_field_required + * Whether or not the "Alt" field is required for this field. + * @param {string} settings.alt + * The current value for the "Alt" field. + * @param {bool} settings.title_field + * Whether or not the "Title" field is enabled for this field. + * @param {bool} settings.title_field_required + * Whether or not the "Title" field is required for this field. + * @param {string} settings.title + * The current value for the "Title" field. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditImageToolbar = function (settings) { + var html = '<form class="quickedit-image-field-info">'; + if (settings.alt_field) { + html += ' <div>' + + ' <label for="alt" class="' + (settings.alt_field_required ? 'required' : '') + '">' + Drupal.t('Alternative text') + '</label>' + + ' <input type="text" placeholder="' + settings.alt + '" value="' + settings.alt + '" name="alt" ' + (settings.alt_field_required ? 'required' : '') + '/>' + + ' </div>'; + } + if (settings.title_field) { + html += ' <div>' + + ' <label for="title" class="' + (settings.title_field_required ? 'form-required' : '') + '">' + Drupal.t('Title') + '</label>' + + ' <input type="text" placeholder="' + settings.title + '" value="' + settings.title + '" name="title" ' + (settings.title_field_required ? 'required' : '') + '/>' + + ' </div>'; + } + html += '</form>'; + + return html; + }; + +})(Drupal); diff --git a/core/modules/image/js/theme.js b/core/modules/image/js/theme.js index cba8f7bbca6b..dadc863203d5 100644 --- a/core/modules/image/js/theme.js +++ b/core/modules/image/js/theme.js @@ -1,86 +1,33 @@ /** - * @file - * Provides theme functions for image Quick Edit's client-side HTML. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/image/js/theme.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal) { 'use strict'; - /** - * Theme function for validation errors of the Image in-place editor. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {string} settings.errors - * Already escaped HTML representing error messages. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditImageErrors = function (settings) { return '<div class="quickedit-image-errors">' + settings.errors + '</div>'; }; - /** - * Theme function for the dropzone element of the Image module's in-place - * editor. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {string} settings.state - * State of the upload. - * @param {string} settings.text - * Text to display inline with the dropzone element. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditImageDropzone = function (settings) { - return '<div class="quickedit-image-dropzone ' + settings.state + '">' + - ' <i class="quickedit-image-icon"></i>' + - ' <span class="quickedit-image-text">' + settings.text + '</span>' + - '</div>'; + return '<div class="quickedit-image-dropzone ' + settings.state + '">' + ' <i class="quickedit-image-icon"></i>' + ' <span class="quickedit-image-text">' + settings.text + '</span>' + '</div>'; }; - /** - * Theme function for the toolbar of the Image module's in-place editor. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {bool} settings.alt_field - * Whether or not the "Alt" field is enabled for this field. - * @param {bool} settings.alt_field_required - * Whether or not the "Alt" field is required for this field. - * @param {string} settings.alt - * The current value for the "Alt" field. - * @param {bool} settings.title_field - * Whether or not the "Title" field is enabled for this field. - * @param {bool} settings.title_field_required - * Whether or not the "Title" field is required for this field. - * @param {string} settings.title - * The current value for the "Title" field. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditImageToolbar = function (settings) { var html = '<form class="quickedit-image-field-info">'; if (settings.alt_field) { - html += ' <div>' + - ' <label for="alt" class="' + (settings.alt_field_required ? 'required' : '') + '">' + Drupal.t('Alternative text') + '</label>' + - ' <input type="text" placeholder="' + settings.alt + '" value="' + settings.alt + '" name="alt" ' + (settings.alt_field_required ? 'required' : '') + '/>' + - ' </div>'; + html += ' <div>' + ' <label for="alt" class="' + (settings.alt_field_required ? 'required' : '') + '">' + Drupal.t('Alternative text') + '</label>' + ' <input type="text" placeholder="' + settings.alt + '" value="' + settings.alt + '" name="alt" ' + (settings.alt_field_required ? 'required' : '') + '/>' + ' </div>'; } if (settings.title_field) { - html += ' <div>' + - ' <label for="title" class="' + (settings.title_field_required ? 'form-required' : '') + '">' + Drupal.t('Title') + '</label>' + - ' <input type="text" placeholder="' + settings.title + '" value="' + settings.title + '" name="title" ' + (settings.title_field_required ? 'required' : '') + '/>' + - ' </div>'; + html += ' <div>' + ' <label for="title" class="' + (settings.title_field_required ? 'form-required' : '') + '">' + Drupal.t('Title') + '</label>' + ' <input type="text" placeholder="' + settings.title + '" value="' + settings.title + '" name="title" ' + (settings.title_field_required ? 'required' : '') + '/>' + ' </div>'; } html += '</form>'; return html; }; - -})(Drupal); +})(Drupal); \ No newline at end of file diff --git a/core/modules/language/language.admin.es6.js b/core/modules/language/language.admin.es6.js new file mode 100644 index 000000000000..9d3daa4ce25f --- /dev/null +++ b/core/modules/language/language.admin.es6.js @@ -0,0 +1,43 @@ +/** + * @file + * Language admin behavior. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Makes language negotiation inherit user interface negotiation. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attach behavior to language negotiation admin user interface. + */ + Drupal.behaviors.negotiationLanguage = { + attach: function () { + var $configForm = $('#language-negotiation-configure-form'); + var inputSelector = 'input[name$="[configurable]"]'; + // Given a customization checkbox derive the language type being changed. + function toggleTable(checkbox) { + var $checkbox = $(checkbox); + // Get the language detection type such as Interface text language + // detection or Content language detection. + $checkbox.closest('.table-language-group') + .find('table, .tabledrag-toggle-weight') + .toggle($checkbox.prop('checked')); + } + + // Bind hide/show and rearrange customization checkboxes. + $configForm.once('negotiation-language-admin-bind').on('change', inputSelector, function (event) { + toggleTable(event.target); + }); + // Initially, hide language detection types that are not customized. + $configForm.find(inputSelector + ':not(:checked)').each(function (index, element) { + toggleTable(element); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/language/language.admin.js b/core/modules/language/language.admin.js index 9d3daa4ce25f..b08a55dfe8b6 100644 --- a/core/modules/language/language.admin.js +++ b/core/modules/language/language.admin.js @@ -1,43 +1,33 @@ /** - * @file - * Language admin behavior. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/language/language.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Makes language negotiation inherit user interface negotiation. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attach behavior to language negotiation admin user interface. - */ Drupal.behaviors.negotiationLanguage = { - attach: function () { + attach: function attach() { var $configForm = $('#language-negotiation-configure-form'); var inputSelector = 'input[name$="[configurable]"]'; - // Given a customization checkbox derive the language type being changed. + function toggleTable(checkbox) { var $checkbox = $(checkbox); - // Get the language detection type such as Interface text language - // detection or Content language detection. - $checkbox.closest('.table-language-group') - .find('table, .tabledrag-toggle-weight') - .toggle($checkbox.prop('checked')); + + $checkbox.closest('.table-language-group').find('table, .tabledrag-toggle-weight').toggle($checkbox.prop('checked')); } - // Bind hide/show and rearrange customization checkboxes. $configForm.once('negotiation-language-admin-bind').on('change', inputSelector, function (event) { toggleTable(event.target); }); - // Initially, hide language detection types that are not customized. + $configForm.find(inputSelector + ':not(:checked)').each(function (index, element) { toggleTable(element); }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/locale/locale.admin.es6.js b/core/modules/locale/locale.admin.es6.js new file mode 100644 index 000000000000..d9701ef94da8 --- /dev/null +++ b/core/modules/locale/locale.admin.es6.js @@ -0,0 +1,116 @@ +/** + * @file + * Locale admin behavior. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Marks changes of translations. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to show the user if translations has changed. + * @prop {Drupal~behaviorDetach} detach + * Detach behavior to show the user if translations has changed. + */ + Drupal.behaviors.localeTranslateDirty = { + attach: function () { + var $form = $('#locale-translate-edit-form').once('localetranslatedirty'); + if ($form.length) { + // Display a notice if any row changed. + $form.one('formUpdated.localeTranslateDirty', 'table', function () { + var $marker = $(Drupal.theme('localeTranslateChangedWarning')).hide(); + $(this).addClass('changed').before($marker); + $marker.fadeIn('slow'); + }); + // Highlight changed row. + $form.on('formUpdated.localeTranslateDirty', 'tr', function () { + var $row = $(this); + var $rowToMark = $row.once('localemark'); + var marker = Drupal.theme('localeTranslateChangedMarker'); + + $row.addClass('changed'); + // Add an asterisk only once if row changed. + if ($rowToMark.length) { + $rowToMark.find('td:first-child .js-form-item').append(marker); + } + }); + } + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + var $form = $('#locale-translate-edit-form').removeOnce('localetranslatedirty'); + if ($form.length) { + $form.off('formUpdated.localeTranslateDirty'); + } + } + } + }; + + /** + * Show/hide the description details on Available translation updates page. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for toggling details on the translation update page. + */ + Drupal.behaviors.hideUpdateInformation = { + attach: function (context, settings) { + var $table = $('#locale-translation-status-form').once('expand-updates'); + if ($table.length) { + var $tbodies = $table.find('tbody'); + + // Open/close the description details by toggling a tr class. + $tbodies.on('click keydown', '.description', function (e) { + if (e.keyCode && (e.keyCode !== 13 && e.keyCode !== 32)) { + return; + } + e.preventDefault(); + var $tr = $(this).closest('tr'); + + $tr.toggleClass('expanded'); + + // Change screen reader text. + $tr.find('.locale-translation-update__prefix').text(function () { + if ($tr.hasClass('expanded')) { + return Drupal.t('Hide description'); + } + else { + return Drupal.t('Show description'); + } + }); + }); + $table.find('.requirements, .links').hide(); + } + } + }; + + $.extend(Drupal.theme, /** @lends Drupal.theme */{ + + /** + * Creates markup for a changed translation marker. + * + * @return {string} + * Markup for the marker. + */ + localeTranslateChangedMarker: function () { + return '<abbr class="warning ajax-changed" title="' + Drupal.t('Changed') + '">*</abbr>'; + }, + + /** + * Creates markup for the translation changed warning. + * + * @return {string} + * Markup for the warning. + */ + localeTranslateChangedWarning: function () { + return '<div class="clearfix messages messages--warning">' + Drupal.theme('localeTranslateChangedMarker') + ' ' + Drupal.t('Changes made in this table will not be saved until the form is submitted.') + '</div>'; + } + }); + +})(jQuery, Drupal); diff --git a/core/modules/locale/locale.admin.js b/core/modules/locale/locale.admin.js index d9701ef94da8..f287542d9b17 100644 --- a/core/modules/locale/locale.admin.js +++ b/core/modules/locale/locale.admin.js @@ -1,47 +1,39 @@ /** - * @file - * Locale admin behavior. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/locale/locale.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Marks changes of translations. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior to show the user if translations has changed. - * @prop {Drupal~behaviorDetach} detach - * Detach behavior to show the user if translations has changed. - */ Drupal.behaviors.localeTranslateDirty = { - attach: function () { + attach: function attach() { var $form = $('#locale-translate-edit-form').once('localetranslatedirty'); if ($form.length) { - // Display a notice if any row changed. $form.one('formUpdated.localeTranslateDirty', 'table', function () { var $marker = $(Drupal.theme('localeTranslateChangedWarning')).hide(); $(this).addClass('changed').before($marker); $marker.fadeIn('slow'); }); - // Highlight changed row. + $form.on('formUpdated.localeTranslateDirty', 'tr', function () { var $row = $(this); var $rowToMark = $row.once('localemark'); var marker = Drupal.theme('localeTranslateChangedMarker'); $row.addClass('changed'); - // Add an asterisk only once if row changed. + if ($rowToMark.length) { $rowToMark.find('td:first-child .js-form-item').append(marker); } }); } }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload') { var $form = $('#locale-translate-edit-form').removeOnce('localetranslatedirty'); if ($form.length) { @@ -51,23 +43,14 @@ } }; - /** - * Show/hide the description details on Available translation updates page. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior for toggling details on the translation update page. - */ Drupal.behaviors.hideUpdateInformation = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $table = $('#locale-translation-status-form').once('expand-updates'); if ($table.length) { var $tbodies = $table.find('tbody'); - // Open/close the description details by toggling a tr class. $tbodies.on('click keydown', '.description', function (e) { - if (e.keyCode && (e.keyCode !== 13 && e.keyCode !== 32)) { + if (e.keyCode && e.keyCode !== 13 && e.keyCode !== 32) { return; } e.preventDefault(); @@ -75,12 +58,10 @@ $tr.toggleClass('expanded'); - // Change screen reader text. $tr.find('.locale-translation-update__prefix').text(function () { if ($tr.hasClass('expanded')) { return Drupal.t('Hide description'); - } - else { + } else { return Drupal.t('Show description'); } }); @@ -90,27 +71,13 @@ } }; - $.extend(Drupal.theme, /** @lends Drupal.theme */{ - - /** - * Creates markup for a changed translation marker. - * - * @return {string} - * Markup for the marker. - */ - localeTranslateChangedMarker: function () { + $.extend(Drupal.theme, { + localeTranslateChangedMarker: function localeTranslateChangedMarker() { return '<abbr class="warning ajax-changed" title="' + Drupal.t('Changed') + '">*</abbr>'; }, - /** - * Creates markup for the translation changed warning. - * - * @return {string} - * Markup for the warning. - */ - localeTranslateChangedWarning: function () { + localeTranslateChangedWarning: function localeTranslateChangedWarning() { return '<div class="clearfix messages messages--warning">' + Drupal.theme('localeTranslateChangedMarker') + ' ' + Drupal.t('Changes made in this table will not be saved until the form is submitted.') + '</div>'; } }); - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/locale/locale.bulk.es6.js b/core/modules/locale/locale.bulk.es6.js new file mode 100644 index 000000000000..c6743e832110 --- /dev/null +++ b/core/modules/locale/locale.bulk.es6.js @@ -0,0 +1,38 @@ +/** + * @file + * Locale behavior. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Select the language code of an imported file based on its filename. + * + * This only works if the file name ends with "LANGCODE.po". + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for preselecting language code based on filename. + */ + Drupal.behaviors.importLanguageCodeSelector = { + attach: function (context, settings) { + var $form = $('#locale-translate-import-form').once('autodetect-lang'); + if ($form.length) { + var $langcode = $form.find('.langcode-input'); + $form.find('.file-import-input') + .on('change', function () { + // If the filename is fully the language code or the filename + // ends with a language code, pre-select that one. + var matches = $(this).val().match(/([^.][\.]*)([\w-]+)\.po$/); + if (matches && $langcode.find('option[value="' + matches[2] + '"]').length) { + $langcode.val(matches[2]); + } + }); + } + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/locale/locale.bulk.js b/core/modules/locale/locale.bulk.js index c6743e832110..e2da97851883 100644 --- a/core/modules/locale/locale.bulk.js +++ b/core/modules/locale/locale.bulk.js @@ -1,38 +1,27 @@ /** - * @file - * Locale behavior. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/locale/locale.bulk.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Select the language code of an imported file based on its filename. - * - * This only works if the file name ends with "LANGCODE.po". - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior for preselecting language code based on filename. - */ Drupal.behaviors.importLanguageCodeSelector = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $form = $('#locale-translate-import-form').once('autodetect-lang'); if ($form.length) { var $langcode = $form.find('.langcode-input'); - $form.find('.file-import-input') - .on('change', function () { - // If the filename is fully the language code or the filename - // ends with a language code, pre-select that one. - var matches = $(this).val().match(/([^.][\.]*)([\w-]+)\.po$/); - if (matches && $langcode.find('option[value="' + matches[2] + '"]').length) { - $langcode.val(matches[2]); - } - }); + $form.find('.file-import-input').on('change', function () { + var matches = $(this).val().match(/([^.][\.]*)([\w-]+)\.po$/); + if (matches && $langcode.find('option[value="' + matches[2] + '"]').length) { + $langcode.val(matches[2]); + } + }); } } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/locale/locale.datepicker.es6.js b/core/modules/locale/locale.datepicker.es6.js new file mode 100644 index 000000000000..a952ade320e7 --- /dev/null +++ b/core/modules/locale/locale.datepicker.es6.js @@ -0,0 +1,88 @@ +/** + * @file + * Datepicker JavaScript for the Locale module. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Attaches language support to the jQuery UI datepicker component. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.localeDatepicker = { + attach: function (context, settings) { + // This code accesses drupalSettings and localized strings via Drupal.t(). + // So this code should run after these are initialized. By placing it in an + // attach behavior this is assured. + $.datepicker.regional['drupal-locale'] = $.extend({ + closeText: Drupal.t('Done'), + prevText: Drupal.t('Prev'), + nextText: Drupal.t('Next'), + currentText: Drupal.t('Today'), + monthNames: [ + Drupal.t('January', {}, {context: 'Long month name'}), + Drupal.t('February', {}, {context: 'Long month name'}), + Drupal.t('March', {}, {context: 'Long month name'}), + Drupal.t('April', {}, {context: 'Long month name'}), + Drupal.t('May', {}, {context: 'Long month name'}), + Drupal.t('June', {}, {context: 'Long month name'}), + Drupal.t('July', {}, {context: 'Long month name'}), + Drupal.t('August', {}, {context: 'Long month name'}), + Drupal.t('September', {}, {context: 'Long month name'}), + Drupal.t('October', {}, {context: 'Long month name'}), + Drupal.t('November', {}, {context: 'Long month name'}), + Drupal.t('December', {}, {context: 'Long month name'}) + ], + monthNamesShort: [ + Drupal.t('Jan'), + Drupal.t('Feb'), + Drupal.t('Mar'), + Drupal.t('Apr'), + Drupal.t('May'), + Drupal.t('Jun'), + Drupal.t('Jul'), + Drupal.t('Aug'), + Drupal.t('Sep'), + Drupal.t('Oct'), + Drupal.t('Nov'), + Drupal.t('Dec') + ], + dayNames: [ + Drupal.t('Sunday'), + Drupal.t('Monday'), + Drupal.t('Tuesday'), + Drupal.t('Wednesday'), + Drupal.t('Thursday'), + Drupal.t('Friday'), + Drupal.t('Saturday') + ], + dayNamesShort: [ + Drupal.t('Sun'), + Drupal.t('Mon'), + Drupal.t('Tue'), + Drupal.t('Wed'), + Drupal.t('Thu'), + Drupal.t('Fri'), + Drupal.t('Sat') + ], + dayNamesMin: [ + Drupal.t('Su'), + Drupal.t('Mo'), + Drupal.t('Tu'), + Drupal.t('We'), + Drupal.t('Th'), + Drupal.t('Fr'), + Drupal.t('Sa') + ], + dateFormat: Drupal.t('mm/dd/yy'), + firstDay: 0, + isRTL: 0 + }, drupalSettings.jquery.ui.datepicker); + $.datepicker.setDefaults($.datepicker.regional['drupal-locale']); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/locale/locale.datepicker.js b/core/modules/locale/locale.datepicker.js index a952ade320e7..424c924cd86c 100644 --- a/core/modules/locale/locale.datepicker.js +++ b/core/modules/locale/locale.datepicker.js @@ -1,82 +1,27 @@ /** - * @file - * Datepicker JavaScript for the Locale module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/locale/locale.datepicker.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Attaches language support to the jQuery UI datepicker component. - * - * @type {Drupal~behavior} - */ Drupal.behaviors.localeDatepicker = { - attach: function (context, settings) { - // This code accesses drupalSettings and localized strings via Drupal.t(). - // So this code should run after these are initialized. By placing it in an - // attach behavior this is assured. + attach: function attach(context, settings) { $.datepicker.regional['drupal-locale'] = $.extend({ closeText: Drupal.t('Done'), prevText: Drupal.t('Prev'), nextText: Drupal.t('Next'), currentText: Drupal.t('Today'), - monthNames: [ - Drupal.t('January', {}, {context: 'Long month name'}), - Drupal.t('February', {}, {context: 'Long month name'}), - Drupal.t('March', {}, {context: 'Long month name'}), - Drupal.t('April', {}, {context: 'Long month name'}), - Drupal.t('May', {}, {context: 'Long month name'}), - Drupal.t('June', {}, {context: 'Long month name'}), - Drupal.t('July', {}, {context: 'Long month name'}), - Drupal.t('August', {}, {context: 'Long month name'}), - Drupal.t('September', {}, {context: 'Long month name'}), - Drupal.t('October', {}, {context: 'Long month name'}), - Drupal.t('November', {}, {context: 'Long month name'}), - Drupal.t('December', {}, {context: 'Long month name'}) - ], - monthNamesShort: [ - Drupal.t('Jan'), - Drupal.t('Feb'), - Drupal.t('Mar'), - Drupal.t('Apr'), - Drupal.t('May'), - Drupal.t('Jun'), - Drupal.t('Jul'), - Drupal.t('Aug'), - Drupal.t('Sep'), - Drupal.t('Oct'), - Drupal.t('Nov'), - Drupal.t('Dec') - ], - dayNames: [ - Drupal.t('Sunday'), - Drupal.t('Monday'), - Drupal.t('Tuesday'), - Drupal.t('Wednesday'), - Drupal.t('Thursday'), - Drupal.t('Friday'), - Drupal.t('Saturday') - ], - dayNamesShort: [ - Drupal.t('Sun'), - Drupal.t('Mon'), - Drupal.t('Tue'), - Drupal.t('Wed'), - Drupal.t('Thu'), - Drupal.t('Fri'), - Drupal.t('Sat') - ], - dayNamesMin: [ - Drupal.t('Su'), - Drupal.t('Mo'), - Drupal.t('Tu'), - Drupal.t('We'), - Drupal.t('Th'), - Drupal.t('Fr'), - Drupal.t('Sa') - ], + monthNames: [Drupal.t('January', {}, { context: 'Long month name' }), Drupal.t('February', {}, { context: 'Long month name' }), Drupal.t('March', {}, { context: 'Long month name' }), Drupal.t('April', {}, { context: 'Long month name' }), Drupal.t('May', {}, { context: 'Long month name' }), Drupal.t('June', {}, { context: 'Long month name' }), Drupal.t('July', {}, { context: 'Long month name' }), Drupal.t('August', {}, { context: 'Long month name' }), Drupal.t('September', {}, { context: 'Long month name' }), Drupal.t('October', {}, { context: 'Long month name' }), Drupal.t('November', {}, { context: 'Long month name' }), Drupal.t('December', {}, { context: 'Long month name' })], + monthNamesShort: [Drupal.t('Jan'), Drupal.t('Feb'), Drupal.t('Mar'), Drupal.t('Apr'), Drupal.t('May'), Drupal.t('Jun'), Drupal.t('Jul'), Drupal.t('Aug'), Drupal.t('Sep'), Drupal.t('Oct'), Drupal.t('Nov'), Drupal.t('Dec')], + dayNames: [Drupal.t('Sunday'), Drupal.t('Monday'), Drupal.t('Tuesday'), Drupal.t('Wednesday'), Drupal.t('Thursday'), Drupal.t('Friday'), Drupal.t('Saturday')], + dayNamesShort: [Drupal.t('Sun'), Drupal.t('Mon'), Drupal.t('Tue'), Drupal.t('Wed'), Drupal.t('Thu'), Drupal.t('Fri'), Drupal.t('Sat')], + dayNamesMin: [Drupal.t('Su'), Drupal.t('Mo'), Drupal.t('Tu'), Drupal.t('We'), Drupal.t('Th'), Drupal.t('Fr'), Drupal.t('Sa')], dateFormat: Drupal.t('mm/dd/yy'), firstDay: 0, isRTL: 0 @@ -84,5 +29,4 @@ $.datepicker.setDefaults($.datepicker.regional['drupal-locale']); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/locale/tests/locale_test.es6.js b/core/modules/locale/tests/locale_test.es6.js new file mode 100644 index 000000000000..ba62b7504e1e --- /dev/null +++ b/core/modules/locale/tests/locale_test.es6.js @@ -0,0 +1,52 @@ +/** + * @file + * JavaScript for locale_test.module. + * + * @ignore + */ + +Drupal.t("Standard Call t"); +Drupal + . + t + ( + "Whitespace Call t" + ) +; + +Drupal.t('Single Quote t'); +Drupal.t('Single Quote \'Escaped\' t'); +Drupal.t('Single Quote ' + 'Concat ' + 'strings ' + 't'); + +Drupal.t("Double Quote t"); +Drupal.t("Double Quote \"Escaped\" t"); +Drupal.t("Double Quote " + "Concat " + "strings " + "t"); + +Drupal.t("Context Unquoted t", {}, {context: "Context string unquoted"}); +Drupal.t("Context Single Quoted t", {}, {'context': "Context string single quoted"}); +Drupal.t("Context Double Quoted t", {}, {"context": "Context string double quoted"}); + +Drupal.t("Context !key Args t", {'!key': 'value'}, {context: "Context string"}); + +Drupal.formatPlural(1, "Standard Call plural", "Standard Call @count plural"); +Drupal + . + formatPlural + ( + 1, + "Whitespace Call plural", + "Whitespace Call @count plural" + ) +; + +Drupal.formatPlural(1, 'Single Quote plural', 'Single Quote @count plural'); +Drupal.formatPlural(1, 'Single Quote \'Escaped\' plural', 'Single Quote \'Escaped\' @count plural'); + +Drupal.formatPlural(1, "Double Quote plural", "Double Quote @count plural"); +Drupal.formatPlural(1, "Double Quote \"Escaped\" plural", "Double Quote \"Escaped\" @count plural"); + +Drupal.formatPlural(1, "Context Unquoted plural", "Context Unquoted @count plural", {}, {context: "Context string unquoted"}); +Drupal.formatPlural(1, "Context Single Quoted plural", "Context Single Quoted @count plural", {}, {'context': "Context string single quoted"}); +Drupal.formatPlural(1, "Context Double Quoted plural", "Context Double Quoted @count plural", {}, {"context": "Context string double quoted"}); + +Drupal.formatPlural(1, "Context !key Args plural", "Context !key Args @count plural", {'!key': 'value'}, {context: "Context string"}); diff --git a/core/modules/locale/tests/locale_test.js b/core/modules/locale/tests/locale_test.js index ba62b7504e1e..def15853e8cd 100644 --- a/core/modules/locale/tests/locale_test.js +++ b/core/modules/locale/tests/locale_test.js @@ -1,18 +1,13 @@ /** - * @file - * JavaScript for locale_test.module. - * - * @ignore - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/locale/tests/locale_test.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ Drupal.t("Standard Call t"); -Drupal - . - t - ( - "Whitespace Call t" - ) -; +Drupal.t("Whitespace Call t"); Drupal.t('Single Quote t'); Drupal.t('Single Quote \'Escaped\' t'); @@ -22,22 +17,14 @@ Drupal.t("Double Quote t"); Drupal.t("Double Quote \"Escaped\" t"); Drupal.t("Double Quote " + "Concat " + "strings " + "t"); -Drupal.t("Context Unquoted t", {}, {context: "Context string unquoted"}); -Drupal.t("Context Single Quoted t", {}, {'context': "Context string single quoted"}); -Drupal.t("Context Double Quoted t", {}, {"context": "Context string double quoted"}); +Drupal.t("Context Unquoted t", {}, { context: "Context string unquoted" }); +Drupal.t("Context Single Quoted t", {}, { 'context': "Context string single quoted" }); +Drupal.t("Context Double Quoted t", {}, { "context": "Context string double quoted" }); -Drupal.t("Context !key Args t", {'!key': 'value'}, {context: "Context string"}); +Drupal.t("Context !key Args t", { '!key': 'value' }, { context: "Context string" }); Drupal.formatPlural(1, "Standard Call plural", "Standard Call @count plural"); -Drupal - . - formatPlural - ( - 1, - "Whitespace Call plural", - "Whitespace Call @count plural" - ) -; +Drupal.formatPlural(1, "Whitespace Call plural", "Whitespace Call @count plural"); Drupal.formatPlural(1, 'Single Quote plural', 'Single Quote @count plural'); Drupal.formatPlural(1, 'Single Quote \'Escaped\' plural', 'Single Quote \'Escaped\' @count plural'); @@ -45,8 +32,8 @@ Drupal.formatPlural(1, 'Single Quote \'Escaped\' plural', 'Single Quote \'Escape Drupal.formatPlural(1, "Double Quote plural", "Double Quote @count plural"); Drupal.formatPlural(1, "Double Quote \"Escaped\" plural", "Double Quote \"Escaped\" @count plural"); -Drupal.formatPlural(1, "Context Unquoted plural", "Context Unquoted @count plural", {}, {context: "Context string unquoted"}); -Drupal.formatPlural(1, "Context Single Quoted plural", "Context Single Quoted @count plural", {}, {'context': "Context string single quoted"}); -Drupal.formatPlural(1, "Context Double Quoted plural", "Context Double Quoted @count plural", {}, {"context": "Context string double quoted"}); +Drupal.formatPlural(1, "Context Unquoted plural", "Context Unquoted @count plural", {}, { context: "Context string unquoted" }); +Drupal.formatPlural(1, "Context Single Quoted plural", "Context Single Quoted @count plural", {}, { 'context': "Context string single quoted" }); +Drupal.formatPlural(1, "Context Double Quoted plural", "Context Double Quoted @count plural", {}, { "context": "Context string double quoted" }); -Drupal.formatPlural(1, "Context !key Args plural", "Context !key Args @count plural", {'!key': 'value'}, {context: "Context string"}); +Drupal.formatPlural(1, "Context !key Args plural", "Context !key Args @count plural", { '!key': 'value' }, { context: "Context string" }); \ No newline at end of file diff --git a/core/modules/media/js/media_form.es6.js b/core/modules/media/js/media_form.es6.js new file mode 100644 index 000000000000..c93eac6644e8 --- /dev/null +++ b/core/modules/media/js/media_form.es6.js @@ -0,0 +1,40 @@ +/** + * @file + * Defines Javascript behaviors for the media form. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Behaviors for summaries for tabs in the media edit form. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behavior for tabs in the media edit form. + */ + Drupal.behaviors.mediaFormSummaries = { + attach: function (context) { + var $context = $(context); + + $context.find('.media-form-author').drupalSetSummary(function (context) { + var $authorContext = $(context); + var name = $authorContext.find('.field--name-uid input').val(); + var date = $authorContext.find('.field--name-created input').val(); + + if (name && date) { + return Drupal.t('By @name on @date', {'@name': name, '@date': date}); + } + else if (name) { + return Drupal.t('By @name', {'@name': name}); + } + else if (date) { + return Drupal.t('Authored on @date', {'@date': date}); + } + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/media/js/media_form.js b/core/modules/media/js/media_form.js index c93eac6644e8..9fe3ee230cff 100644 --- a/core/modules/media/js/media_form.js +++ b/core/modules/media/js/media_form.js @@ -1,22 +1,17 @@ /** - * @file - * Defines Javascript behaviors for the media form. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/media/js/media_form.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Behaviors for summaries for tabs in the media edit form. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behavior for tabs in the media edit form. - */ Drupal.behaviors.mediaFormSummaries = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); $context.find('.media-form-author').drupalSetSummary(function (context) { @@ -25,16 +20,13 @@ var date = $authorContext.find('.field--name-created input').val(); if (name && date) { - return Drupal.t('By @name on @date', {'@name': name, '@date': date}); - } - else if (name) { - return Drupal.t('By @name', {'@name': name}); - } - else if (date) { - return Drupal.t('Authored on @date', {'@date': date}); + return Drupal.t('By @name on @date', { '@name': name, '@date': date }); + } else if (name) { + return Drupal.t('By @name', { '@name': name }); + } else if (date) { + return Drupal.t('Authored on @date', { '@date': date }); } }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/media/js/media_type_form.es6.js b/core/modules/media/js/media_type_form.es6.js new file mode 100644 index 000000000000..0c1d906e0178 --- /dev/null +++ b/core/modules/media/js/media_type_form.es6.js @@ -0,0 +1,46 @@ +/** + * @file + * Defines JavaScript behaviors for the media type form. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Behaviors for setting summaries on media type form. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behaviors on media type edit forms. + */ + Drupal.behaviors.mediaTypeFormSummaries = { + attach: function (context) { + var $context = $(context); + // Provide the vertical tab summaries. + $context.find('#edit-workflow').drupalSetSummary(function (context) { + var vals = []; + $(context).find('input[name^="options"]:checked').parent().each(function () { + vals.push(Drupal.checkPlain($(this).find('label').text())); + }); + if (!$(context).find('#edit-options-status').is(':checked')) { + vals.unshift(Drupal.t('Not published')); + } + return vals.join(', '); + }); + $(context).find('#edit-language').drupalSetSummary(function (context) { + var vals = []; + + vals.push($(context).find('.js-form-item-language-configuration-langcode select option:selected').text()); + + $(context).find('input:checked').next('label').each(function () { + vals.push(Drupal.checkPlain($(this).text())); + }); + + return vals.join(', '); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/media/js/media_type_form.js b/core/modules/media/js/media_type_form.js index 0c1d906e0178..d5c4a0306104 100644 --- a/core/modules/media/js/media_type_form.js +++ b/core/modules/media/js/media_type_form.js @@ -1,24 +1,19 @@ /** - * @file - * Defines JavaScript behaviors for the media type form. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/media/js/media_type_form.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Behaviors for setting summaries on media type form. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behaviors on media type edit forms. - */ Drupal.behaviors.mediaTypeFormSummaries = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); - // Provide the vertical tab summaries. + $context.find('#edit-workflow').drupalSetSummary(function (context) { var vals = []; $(context).find('input[name^="options"]:checked').parent().each(function () { @@ -42,5 +37,4 @@ }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/menu_ui/menu_ui.admin.es6.js b/core/modules/menu_ui/menu_ui.admin.es6.js new file mode 100644 index 000000000000..345138f17a47 --- /dev/null +++ b/core/modules/menu_ui/menu_ui.admin.es6.js @@ -0,0 +1,68 @@ +/** + * @file + * Menu UI admin behaviors. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.menuUiChangeParentItems = { + attach: function (context, settings) { + var $menu = $('#edit-menu').once('menu-parent'); + if ($menu.length) { + // Update the list of available parent menu items to match the initial + // available menus. + Drupal.menuUiUpdateParentList(); + + // Update list of available parent menu items. + $menu.on('change', 'input', Drupal.menuUiUpdateParentList); + } + } + }; + + /** + * Function to set the options of the menu parent item dropdown. + */ + Drupal.menuUiUpdateParentList = function () { + var $menu = $('#edit-menu'); + var values = []; + + $menu.find('input:checked').each(function () { + // Get the names of all checked menus. + values.push(Drupal.checkPlain($.trim($(this).val()))); + }); + + $.ajax({ + url: location.protocol + '//' + location.host + Drupal.url('admin/structure/menu/parents'), + type: 'POST', + data: {'menus[]': values}, + dataType: 'json', + success: function (options) { + var $select = $('#edit-menu-parent'); + // Save key of last selected element. + var selected = $select.val(); + // Remove all existing options from dropdown. + $select.children().remove(); + // Add new options to dropdown. Keep a count of options for testing later. + var totalOptions = 0; + for (var machineName in options) { + if (options.hasOwnProperty(machineName)) { + $select.append( + $('<option ' + (machineName === selected ? ' selected="selected"' : '') + '></option>').val(machineName).text(options[machineName]) + ); + totalOptions++; + } + } + + // Hide the parent options if there are no options for it. + $select.closest('div').toggle(totalOptions > 0).attr('hidden', totalOptions === 0); + } + }); + }; + +})(jQuery, Drupal); diff --git a/core/modules/menu_ui/menu_ui.admin.js b/core/modules/menu_ui/menu_ui.admin.js index 345138f17a47..854d05114232 100644 --- a/core/modules/menu_ui/menu_ui.admin.js +++ b/core/modules/menu_ui/menu_ui.admin.js @@ -1,68 +1,56 @@ /** - * @file - * Menu UI admin behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/menu_ui/menu_ui.admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * - * @type {Drupal~behavior} - */ Drupal.behaviors.menuUiChangeParentItems = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $menu = $('#edit-menu').once('menu-parent'); if ($menu.length) { - // Update the list of available parent menu items to match the initial - // available menus. Drupal.menuUiUpdateParentList(); - // Update list of available parent menu items. $menu.on('change', 'input', Drupal.menuUiUpdateParentList); } } }; - /** - * Function to set the options of the menu parent item dropdown. - */ Drupal.menuUiUpdateParentList = function () { var $menu = $('#edit-menu'); var values = []; $menu.find('input:checked').each(function () { - // Get the names of all checked menus. values.push(Drupal.checkPlain($.trim($(this).val()))); }); $.ajax({ url: location.protocol + '//' + location.host + Drupal.url('admin/structure/menu/parents'), type: 'POST', - data: {'menus[]': values}, + data: { 'menus[]': values }, dataType: 'json', - success: function (options) { + success: function success(options) { var $select = $('#edit-menu-parent'); - // Save key of last selected element. + var selected = $select.val(); - // Remove all existing options from dropdown. + $select.children().remove(); - // Add new options to dropdown. Keep a count of options for testing later. + var totalOptions = 0; for (var machineName in options) { if (options.hasOwnProperty(machineName)) { - $select.append( - $('<option ' + (machineName === selected ? ' selected="selected"' : '') + '></option>').val(machineName).text(options[machineName]) - ); + $select.append($('<option ' + (machineName === selected ? ' selected="selected"' : '') + '></option>').val(machineName).text(options[machineName])); totalOptions++; } } - // Hide the parent options if there are no options for it. $select.closest('div').toggle(totalOptions > 0).attr('hidden', totalOptions === 0); } }); }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/menu_ui/menu_ui.es6.js b/core/modules/menu_ui/menu_ui.es6.js new file mode 100644 index 000000000000..5eb10e0781c8 --- /dev/null +++ b/core/modules/menu_ui/menu_ui.es6.js @@ -0,0 +1,91 @@ +/** + * @file + * Menu UI behaviors. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Set a summary on the menu link form. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Find the form and call `drupalSetSummary` on it. + */ + Drupal.behaviors.menuUiDetailsSummaries = { + attach: function (context) { + $(context).find('.menu-link-form').drupalSetSummary(function (context) { + var $context = $(context); + if ($context.find('.js-form-item-menu-enabled input').is(':checked')) { + return Drupal.checkPlain($context.find('.js-form-item-menu-title input').val()); + } + else { + return Drupal.t('Not in menu'); + } + }); + } + }; + + /** + * Automatically fill in a menu link title, if possible. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches change and keyup behavior for automatically filling out menu + * link titles. + */ + Drupal.behaviors.menuUiLinkAutomaticTitle = { + attach: function (context) { + var $context = $(context); + $context.find('.menu-link-form').each(function () { + var $this = $(this); + // Try to find menu settings widget elements as well as a 'title' field + // in the form, but play nicely with user permissions and form + // alterations. + var $checkbox = $this.find('.js-form-item-menu-enabled input'); + var $link_title = $context.find('.js-form-item-menu-title input'); + var $title = $this.closest('form').find('.js-form-item-title-0-value input'); + // Bail out if we do not have all required fields. + if (!($checkbox.length && $link_title.length && $title.length)) { + return; + } + // If there is a link title already, mark it as overridden. The user + // expects that toggling the checkbox twice will take over the node's + // title. + if ($checkbox.is(':checked') && $link_title.val().length) { + $link_title.data('menuLinkAutomaticTitleOverridden', true); + } + // Whenever the value is changed manually, disable this behavior. + $link_title.on('keyup', function () { + $link_title.data('menuLinkAutomaticTitleOverridden', true); + }); + // Global trigger on checkbox (do not fill-in a value when disabled). + $checkbox.on('change', function () { + if ($checkbox.is(':checked')) { + if (!$link_title.data('menuLinkAutomaticTitleOverridden')) { + $link_title.val($title.val()); + } + } + else { + $link_title.val(''); + $link_title.removeData('menuLinkAutomaticTitleOverridden'); + } + $checkbox.closest('.vertical-tabs-pane').trigger('summaryUpdated'); + $checkbox.trigger('formUpdated'); + }); + // Take over any title change. + $title.on('keyup', function () { + if (!$link_title.data('menuLinkAutomaticTitleOverridden') && $checkbox.is(':checked')) { + $link_title.val($title.val()); + $link_title.val($title.val()).trigger('formUpdated'); + } + }); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/menu_ui/menu_ui.js b/core/modules/menu_ui/menu_ui.js index 5eb10e0781c8..0d802148ba3b 100644 --- a/core/modules/menu_ui/menu_ui.js +++ b/core/modules/menu_ui/menu_ui.js @@ -1,83 +1,63 @@ /** - * @file - * Menu UI behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/menu_ui/menu_ui.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Set a summary on the menu link form. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Find the form and call `drupalSetSummary` on it. - */ Drupal.behaviors.menuUiDetailsSummaries = { - attach: function (context) { + attach: function attach(context) { $(context).find('.menu-link-form').drupalSetSummary(function (context) { var $context = $(context); if ($context.find('.js-form-item-menu-enabled input').is(':checked')) { return Drupal.checkPlain($context.find('.js-form-item-menu-title input').val()); - } - else { + } else { return Drupal.t('Not in menu'); } }); } }; - /** - * Automatically fill in a menu link title, if possible. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches change and keyup behavior for automatically filling out menu - * link titles. - */ Drupal.behaviors.menuUiLinkAutomaticTitle = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); $context.find('.menu-link-form').each(function () { var $this = $(this); - // Try to find menu settings widget elements as well as a 'title' field - // in the form, but play nicely with user permissions and form - // alterations. + var $checkbox = $this.find('.js-form-item-menu-enabled input'); var $link_title = $context.find('.js-form-item-menu-title input'); var $title = $this.closest('form').find('.js-form-item-title-0-value input'); - // Bail out if we do not have all required fields. + if (!($checkbox.length && $link_title.length && $title.length)) { return; } - // If there is a link title already, mark it as overridden. The user - // expects that toggling the checkbox twice will take over the node's - // title. + if ($checkbox.is(':checked') && $link_title.val().length) { $link_title.data('menuLinkAutomaticTitleOverridden', true); } - // Whenever the value is changed manually, disable this behavior. + $link_title.on('keyup', function () { $link_title.data('menuLinkAutomaticTitleOverridden', true); }); - // Global trigger on checkbox (do not fill-in a value when disabled). + $checkbox.on('change', function () { if ($checkbox.is(':checked')) { if (!$link_title.data('menuLinkAutomaticTitleOverridden')) { $link_title.val($title.val()); } - } - else { + } else { $link_title.val(''); $link_title.removeData('menuLinkAutomaticTitleOverridden'); } $checkbox.closest('.vertical-tabs-pane').trigger('summaryUpdated'); $checkbox.trigger('formUpdated'); }); - // Take over any title change. + $title.on('keyup', function () { if (!$link_title.data('menuLinkAutomaticTitleOverridden') && $checkbox.is(':checked')) { $link_title.val($title.val()); @@ -87,5 +67,4 @@ }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/node/content_types.es6.js b/core/modules/node/content_types.es6.js new file mode 100644 index 000000000000..4ec34aa05e4f --- /dev/null +++ b/core/modules/node/content_types.es6.js @@ -0,0 +1,62 @@ +/** + * @file + * Javascript for the node content editing form. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Behaviors for setting summaries on content type form. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behaviors on content type edit forms. + */ + Drupal.behaviors.contentTypes = { + attach: function (context) { + var $context = $(context); + // Provide the vertical tab summaries. + $context.find('#edit-submission').drupalSetSummary(function (context) { + var vals = []; + vals.push(Drupal.checkPlain($(context).find('#edit-title-label').val()) || Drupal.t('Requires a title')); + return vals.join(', '); + }); + $context.find('#edit-workflow').drupalSetSummary(function (context) { + var vals = []; + $(context).find('input[name^="options"]:checked').next('label').each(function () { + vals.push(Drupal.checkPlain($(this).text())); + }); + if (!$(context).find('#edit-options-status').is(':checked')) { + vals.unshift(Drupal.t('Not published')); + } + return vals.join(', '); + }); + $('#edit-language', context).drupalSetSummary(function (context) { + var vals = []; + + vals.push($('.js-form-item-language-configuration-langcode select option:selected', context).text()); + + $('input:checked', context).next('label').each(function () { + vals.push(Drupal.checkPlain($(this).text())); + }); + + return vals.join(', '); + }); + $context.find('#edit-display').drupalSetSummary(function (context) { + var vals = []; + var $editContext = $(context); + $editContext.find('input:checked').next('label').each(function () { + vals.push(Drupal.checkPlain($(this).text())); + }); + if (!$editContext.find('#edit-display-submitted').is(':checked')) { + vals.unshift(Drupal.t("Don't display post information")); + } + return vals.join(', '); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/node/content_types.js b/core/modules/node/content_types.js index 4ec34aa05e4f..832f17a97871 100644 --- a/core/modules/node/content_types.js +++ b/core/modules/node/content_types.js @@ -1,24 +1,19 @@ /** - * @file - * Javascript for the node content editing form. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/node/content_types.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Behaviors for setting summaries on content type form. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behaviors on content type edit forms. - */ Drupal.behaviors.contentTypes = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); - // Provide the vertical tab summaries. + $context.find('#edit-submission').drupalSetSummary(function (context) { var vals = []; vals.push(Drupal.checkPlain($(context).find('#edit-title-label').val()) || Drupal.t('Requires a title')); @@ -58,5 +53,4 @@ }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/node/node.es6.js b/core/modules/node/node.es6.js new file mode 100644 index 000000000000..086263d2a8e4 --- /dev/null +++ b/core/modules/node/node.es6.js @@ -0,0 +1,55 @@ +/** + * @file + * Defines Javascript behaviors for the node module. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Behaviors for tabs in the node edit form. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behavior for tabs in the node edit form. + */ + Drupal.behaviors.nodeDetailsSummaries = { + attach: function (context) { + var $context = $(context); + + $context.find('.node-form-author').drupalSetSummary(function (context) { + var $authorContext = $(context); + var name = $authorContext.find('.field--name-uid input').val(); + var date = $authorContext.find('.field--name-created input').val(); + + if (name && date) { + return Drupal.t('By @name on @date', {'@name': name, '@date': date}); + } + else if (name) { + return Drupal.t('By @name', {'@name': name}); + } + else if (date) { + return Drupal.t('Authored on @date', {'@date': date}); + } + }); + + $context.find('.node-form-options').drupalSetSummary(function (context) { + var $optionsContext = $(context); + var vals = []; + + if ($optionsContext.find('input').is(':checked')) { + $optionsContext.find('input:checked').next('label').each(function () { + vals.push(Drupal.checkPlain($.trim($(this).text()))); + }); + return vals.join(', '); + } + else { + return Drupal.t('Not promoted'); + } + }); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/node/node.js b/core/modules/node/node.js index 086263d2a8e4..76859aed54fe 100644 --- a/core/modules/node/node.js +++ b/core/modules/node/node.js @@ -1,22 +1,17 @@ /** - * @file - * Defines Javascript behaviors for the node module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/node/node.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Behaviors for tabs in the node edit form. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behavior for tabs in the node edit form. - */ Drupal.behaviors.nodeDetailsSummaries = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); $context.find('.node-form-author').drupalSetSummary(function (context) { @@ -25,13 +20,11 @@ var date = $authorContext.find('.field--name-created input').val(); if (name && date) { - return Drupal.t('By @name on @date', {'@name': name, '@date': date}); - } - else if (name) { - return Drupal.t('By @name', {'@name': name}); - } - else if (date) { - return Drupal.t('Authored on @date', {'@date': date}); + return Drupal.t('By @name on @date', { '@name': name, '@date': date }); + } else if (name) { + return Drupal.t('By @name', { '@name': name }); + } else if (date) { + return Drupal.t('Authored on @date', { '@date': date }); } }); @@ -44,12 +37,10 @@ vals.push(Drupal.checkPlain($.trim($(this).text()))); }); return vals.join(', '); - } - else { + } else { return Drupal.t('Not promoted'); } }); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/node/node.preview.es6.js b/core/modules/node/node.preview.es6.js new file mode 100644 index 000000000000..4054183a1f75 --- /dev/null +++ b/core/modules/node/node.preview.es6.js @@ -0,0 +1,99 @@ +/** + * @file + * Preview behaviors. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Disables all non-relevant links in node previews. + * + * Destroys links (except local fragment identifiers such as href="#frag") in + * node previews to prevent users from leaving the page. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches confirmation prompt for clicking links in node preview mode. + * @prop {Drupal~behaviorDetach} detach + * Detaches confirmation prompt for clicking links in node preview mode. + */ + Drupal.behaviors.nodePreviewDestroyLinks = { + attach: function (context) { + + function clickPreviewModal(event) { + // Only confirm leaving previews when left-clicking and user is not + // pressing the ALT, CTRL, META (Command key on the Macintosh keyboard) + // or SHIFT key. + if (event.button === 0 && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { + event.preventDefault(); + var $previewDialog = $('<div>' + Drupal.theme('nodePreviewModal') + '</div>').appendTo('body'); + Drupal.dialog($previewDialog, { + title: Drupal.t('Leave preview?'), + buttons: [ + { + text: Drupal.t('Cancel'), + click: function () { + $(this).dialog('close'); + } + }, { + text: Drupal.t('Leave preview'), + click: function () { + window.top.location.href = event.target.href; + } + } + ] + }).showModal(); + } + } + + var $preview = $(context).find('.content').once('node-preview'); + if ($(context).find('.node-preview-container').length) { + $preview.on('click.preview', 'a:not([href^=#], #edit-backlink, #toolbar-administration a)', clickPreviewModal); + } + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + var $preview = $(context).find('.content').removeOnce('node-preview'); + if ($preview.length) { + $preview.off('click.preview'); + } + } + } + }; + + /** + * Switch view mode. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches automatic submit on `formUpdated.preview` events. + */ + Drupal.behaviors.nodePreviewSwitchViewMode = { + attach: function (context) { + var $autosubmit = $(context).find('[data-drupal-autosubmit]').once('autosubmit'); + if ($autosubmit.length) { + $autosubmit.on('formUpdated.preview', function () { + $(this.form).trigger('submit'); + }); + } + } + }; + + /** + * Theme function for node preview modal. + * + * @return {string} + * Markup for the node preview modal. + */ + Drupal.theme.nodePreviewModal = function () { + return '<p>' + + Drupal.t('Leaving the preview will cause unsaved changes to be lost. Are you sure you want to leave the preview?') + + '</p><small class="description">' + + Drupal.t('CTRL+Left click will prevent this dialog from showing and proceed to the clicked link.') + '</small>'; + }; + +})(jQuery, Drupal); diff --git a/core/modules/node/node.preview.js b/core/modules/node/node.preview.js index 4054183a1f75..1ac13fefa58a 100644 --- a/core/modules/node/node.preview.js +++ b/core/modules/node/node.preview.js @@ -1,50 +1,35 @@ /** - * @file - * Preview behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/node/node.preview.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Disables all non-relevant links in node previews. - * - * Destroys links (except local fragment identifiers such as href="#frag") in - * node previews to prevent users from leaving the page. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches confirmation prompt for clicking links in node preview mode. - * @prop {Drupal~behaviorDetach} detach - * Detaches confirmation prompt for clicking links in node preview mode. - */ Drupal.behaviors.nodePreviewDestroyLinks = { - attach: function (context) { + attach: function attach(context) { function clickPreviewModal(event) { - // Only confirm leaving previews when left-clicking and user is not - // pressing the ALT, CTRL, META (Command key on the Macintosh keyboard) - // or SHIFT key. if (event.button === 0 && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { event.preventDefault(); var $previewDialog = $('<div>' + Drupal.theme('nodePreviewModal') + '</div>').appendTo('body'); Drupal.dialog($previewDialog, { title: Drupal.t('Leave preview?'), - buttons: [ - { - text: Drupal.t('Cancel'), - click: function () { - $(this).dialog('close'); - } - }, { - text: Drupal.t('Leave preview'), - click: function () { - window.top.location.href = event.target.href; - } + buttons: [{ + text: Drupal.t('Cancel'), + click: function click() { + $(this).dialog('close'); } - ] + }, { + text: Drupal.t('Leave preview'), + click: function click() { + window.top.location.href = event.target.href; + } + }] }).showModal(); } } @@ -54,7 +39,7 @@ $preview.on('click.preview', 'a:not([href^=#], #edit-backlink, #toolbar-administration a)', clickPreviewModal); } }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload') { var $preview = $(context).find('.content').removeOnce('node-preview'); if ($preview.length) { @@ -64,16 +49,8 @@ } }; - /** - * Switch view mode. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches automatic submit on `formUpdated.preview` events. - */ Drupal.behaviors.nodePreviewSwitchViewMode = { - attach: function (context) { + attach: function attach(context) { var $autosubmit = $(context).find('[data-drupal-autosubmit]').once('autosubmit'); if ($autosubmit.length) { $autosubmit.on('formUpdated.preview', function () { @@ -83,17 +60,7 @@ } }; - /** - * Theme function for node preview modal. - * - * @return {string} - * Markup for the node preview modal. - */ Drupal.theme.nodePreviewModal = function () { - return '<p>' + - Drupal.t('Leaving the preview will cause unsaved changes to be lost. Are you sure you want to leave the preview?') + - '</p><small class="description">' + - Drupal.t('CTRL+Left click will prevent this dialog from showing and proceed to the clicked link.') + '</small>'; + return '<p>' + Drupal.t('Leaving the preview will cause unsaved changes to be lost. Are you sure you want to leave the preview?') + '</p><small class="description">' + Drupal.t('CTRL+Left click will prevent this dialog from showing and proceed to the clicked link.') + '</small>'; }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/outside_in/js/off-canvas.es6.js b/core/modules/outside_in/js/off-canvas.es6.js new file mode 100644 index 000000000000..d7c8d906f6ba --- /dev/null +++ b/core/modules/outside_in/js/off-canvas.es6.js @@ -0,0 +1,160 @@ +/** + * @file + * Drupal's off-canvas library. + * + * @todo This functionality should extracted into a new core library or a part + * of the current drupal.dialog.ajax library. + * https://www.drupal.org/node/2784443 + */ + +(function ($, Drupal, debounce, displace) { + + 'use strict'; + + // The minimum width to use body displace needs to match the width at which + // the tray will be %100 width. @see outside_in.module.css + var minDisplaceWidth = 768; + + /** + * The edge of the screen that the dialog should appear on. + * + * @type {string} + */ + var edge = document.documentElement.dir === 'rtl' ? 'left' : 'right'; + + var $mainCanvasWrapper = $('[data-off-canvas-main-canvas]'); + + /** + * Resets the size of the dialog. + * + * @param {jQuery.Event} event + * The event triggered. + */ + function resetSize(event) { + var offsets = displace.offsets; + var $element = event.data.$element; + var $widget = $element.dialog('widget'); + + var adjustedOptions = { + // @see http://api.jqueryui.com/position/ + position: { + my: edge + ' top', + at: edge + ' top' + (offsets.top !== 0 ? '+' + offsets.top : ''), + of: window + } + }; + + $widget.css({ + position: 'fixed', + height: ($(window).height() - (offsets.top + offsets.bottom)) + 'px' + }); + + $element + .dialog('option', adjustedOptions) + .trigger('dialogContentResize.off-canvas'); + } + + /** + * Adjusts the dialog on resize. + * + * @param {jQuery.Event} event + * The event triggered. + */ + function handleDialogResize(event) { + var $element = event.data.$element; + var $widget = $element.dialog('widget'); + + var $offsets = $widget.find('> :not(#drupal-off-canvas, .ui-resizable-handle)'); + var offset = 0; + var modalHeight; + + // Let scroll element take all the height available. + $element.css({height: 'auto'}); + modalHeight = $widget.height(); + $offsets.each(function () { offset += $(this).outerHeight(); }); + + // Take internal padding into account. + var scrollOffset = $element.outerHeight() - $element.height(); + $element.height(modalHeight - offset - scrollOffset); + } + + /** + * Adjusts the body padding when the dialog is resized. + * + * @param {jQuery.Event} event + * The event triggered. + */ + function bodyPadding(event) { + if ($('body').outerWidth() < minDisplaceWidth) { + return; + } + var $element = event.data.$element; + var $widget = $element.dialog('widget'); + + var width = $widget.outerWidth(); + var mainCanvasPadding = $mainCanvasWrapper.css('padding-' + edge); + if (width !== mainCanvasPadding) { + $mainCanvasWrapper.css('padding-' + edge, width + 'px'); + $widget.attr('data-offset-' + edge, width); + displace(); + } + } + + /** + * Attaches off-canvas dialog behaviors. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches event listeners for off-canvas dialogs. + */ + Drupal.behaviors.offCanvasEvents = { + attach: function () { + $(window).once('off-canvas').on({ + 'dialog:aftercreate': function (event, dialog, $element, settings) { + if ($element.is('#drupal-off-canvas')) { + var eventData = {settings: settings, $element: $element}; + $('.ui-dialog-off-canvas, .ui-dialog-off-canvas .ui-dialog-titlebar').toggleClass('ui-dialog-empty-title', !settings.title); + + $element + .on('dialogresize.off-canvas', eventData, debounce(bodyPadding, 100)) + .on('dialogContentResize.off-canvas', eventData, handleDialogResize) + .on('dialogContentResize.off-canvas', eventData, debounce(bodyPadding, 100)) + .trigger('dialogresize.off-canvas'); + + $element.dialog('widget').attr('data-offset-' + edge, ''); + + $(window) + .on('resize.off-canvas scroll.off-canvas', eventData, debounce(resetSize, 100)) + .trigger('resize.off-canvas'); + } + }, + 'dialog:beforecreate': function (event, dialog, $element, settings) { + if ($element.is('#drupal-off-canvas')) { + $('body').addClass('js-tray-open'); + // @see http://api.jqueryui.com/position/ + settings.position = { + my: 'left top', + at: edge + ' top', + of: window + }; + settings.dialogClass += ' ui-dialog-off-canvas'; + // Applies initial height to dialog based on window height. + // See http://api.jqueryui.com/dialog for all dialog options. + settings.height = $(window).height(); + } + }, + 'dialog:beforeclose': function (event, dialog, $element) { + if ($element.is('#drupal-off-canvas')) { + $('body').removeClass('js-tray-open'); + // Remove all *.off-canvas events + $(document).off('.off-canvas'); + $(window).off('.off-canvas'); + $mainCanvasWrapper.css('padding-' + edge, 0); + } + } + }); + } + }; + +})(jQuery, Drupal, Drupal.debounce, Drupal.displace); diff --git a/core/modules/outside_in/js/off-canvas.js b/core/modules/outside_in/js/off-canvas.js index d7c8d906f6ba..c52e699058df 100644 --- a/core/modules/outside_in/js/off-canvas.js +++ b/core/modules/outside_in/js/off-canvas.js @@ -1,42 +1,27 @@ /** - * @file - * Drupal's off-canvas library. - * - * @todo This functionality should extracted into a new core library or a part - * of the current drupal.dialog.ajax library. - * https://www.drupal.org/node/2784443 - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/outside_in/js/off-canvas.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, debounce, displace) { 'use strict'; - // The minimum width to use body displace needs to match the width at which - // the tray will be %100 width. @see outside_in.module.css var minDisplaceWidth = 768; - /** - * The edge of the screen that the dialog should appear on. - * - * @type {string} - */ var edge = document.documentElement.dir === 'rtl' ? 'left' : 'right'; var $mainCanvasWrapper = $('[data-off-canvas-main-canvas]'); - /** - * Resets the size of the dialog. - * - * @param {jQuery.Event} event - * The event triggered. - */ function resetSize(event) { var offsets = displace.offsets; var $element = event.data.$element; var $widget = $element.dialog('widget'); var adjustedOptions = { - // @see http://api.jqueryui.com/position/ position: { my: edge + ' top', at: edge + ' top' + (offsets.top !== 0 ? '+' + offsets.top : ''), @@ -46,20 +31,12 @@ $widget.css({ position: 'fixed', - height: ($(window).height() - (offsets.top + offsets.bottom)) + 'px' + height: $(window).height() - (offsets.top + offsets.bottom) + 'px' }); - $element - .dialog('option', adjustedOptions) - .trigger('dialogContentResize.off-canvas'); + $element.dialog('option', adjustedOptions).trigger('dialogContentResize.off-canvas'); } - /** - * Adjusts the dialog on resize. - * - * @param {jQuery.Event} event - * The event triggered. - */ function handleDialogResize(event) { var $element = event.data.$element; var $widget = $element.dialog('widget'); @@ -68,22 +45,16 @@ var offset = 0; var modalHeight; - // Let scroll element take all the height available. - $element.css({height: 'auto'}); + $element.css({ height: 'auto' }); modalHeight = $widget.height(); - $offsets.each(function () { offset += $(this).outerHeight(); }); + $offsets.each(function () { + offset += $(this).outerHeight(); + }); - // Take internal padding into account. var scrollOffset = $element.outerHeight() - $element.height(); $element.height(modalHeight - offset - scrollOffset); } - /** - * Adjusts the body padding when the dialog is resized. - * - * @param {jQuery.Event} event - * The event triggered. - */ function bodyPadding(event) { if ($('body').outerWidth() < minDisplaceWidth) { return; @@ -100,54 +71,39 @@ } } - /** - * Attaches off-canvas dialog behaviors. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches event listeners for off-canvas dialogs. - */ Drupal.behaviors.offCanvasEvents = { - attach: function () { + attach: function attach() { $(window).once('off-canvas').on({ - 'dialog:aftercreate': function (event, dialog, $element, settings) { + 'dialog:aftercreate': function dialogAftercreate(event, dialog, $element, settings) { if ($element.is('#drupal-off-canvas')) { - var eventData = {settings: settings, $element: $element}; + var eventData = { settings: settings, $element: $element }; $('.ui-dialog-off-canvas, .ui-dialog-off-canvas .ui-dialog-titlebar').toggleClass('ui-dialog-empty-title', !settings.title); - $element - .on('dialogresize.off-canvas', eventData, debounce(bodyPadding, 100)) - .on('dialogContentResize.off-canvas', eventData, handleDialogResize) - .on('dialogContentResize.off-canvas', eventData, debounce(bodyPadding, 100)) - .trigger('dialogresize.off-canvas'); + $element.on('dialogresize.off-canvas', eventData, debounce(bodyPadding, 100)).on('dialogContentResize.off-canvas', eventData, handleDialogResize).on('dialogContentResize.off-canvas', eventData, debounce(bodyPadding, 100)).trigger('dialogresize.off-canvas'); $element.dialog('widget').attr('data-offset-' + edge, ''); - $(window) - .on('resize.off-canvas scroll.off-canvas', eventData, debounce(resetSize, 100)) - .trigger('resize.off-canvas'); + $(window).on('resize.off-canvas scroll.off-canvas', eventData, debounce(resetSize, 100)).trigger('resize.off-canvas'); } }, - 'dialog:beforecreate': function (event, dialog, $element, settings) { + 'dialog:beforecreate': function dialogBeforecreate(event, dialog, $element, settings) { if ($element.is('#drupal-off-canvas')) { $('body').addClass('js-tray-open'); - // @see http://api.jqueryui.com/position/ + settings.position = { my: 'left top', at: edge + ' top', of: window }; settings.dialogClass += ' ui-dialog-off-canvas'; - // Applies initial height to dialog based on window height. - // See http://api.jqueryui.com/dialog for all dialog options. + settings.height = $(window).height(); } }, - 'dialog:beforeclose': function (event, dialog, $element) { + 'dialog:beforeclose': function dialogBeforeclose(event, dialog, $element) { if ($element.is('#drupal-off-canvas')) { $('body').removeClass('js-tray-open'); - // Remove all *.off-canvas events + $(document).off('.off-canvas'); $(window).off('.off-canvas'); $mainCanvasWrapper.css('padding-' + edge, 0); @@ -156,5 +112,4 @@ }); } }; - -})(jQuery, Drupal, Drupal.debounce, Drupal.displace); +})(jQuery, Drupal, Drupal.debounce, Drupal.displace); \ No newline at end of file diff --git a/core/modules/outside_in/js/outside_in.es6.js b/core/modules/outside_in/js/outside_in.es6.js new file mode 100644 index 000000000000..eea9075cab52 --- /dev/null +++ b/core/modules/outside_in/js/outside_in.es6.js @@ -0,0 +1,265 @@ +/** + * @file + * Drupal's Settings Tray library. + */ + +(function ($, Drupal) { + + 'use strict'; + + var blockConfigureSelector = '[data-outside-in-edit]'; + var toggleEditSelector = '[data-drupal-outsidein="toggle"]'; + var itemsToToggleSelector = '[data-off-canvas-main-canvas], #toolbar-bar, [data-drupal-outsidein="editable"] a, [data-drupal-outsidein="editable"] button'; + var contextualItemsSelector = '[data-contextual-id] a, [data-contextual-id] button'; + var quickEditItemSelector = '[data-quickedit-entity-id]'; + + /** + * Reacts to contextual links being added. + * + * @param {jQuery.Event} event + * The `drupalContextualLinkAdded` event. + * @param {object} data + * An object containing the data relevant to the event. + * + * @listens event:drupalContextualLinkAdded + */ + $(document).on('drupalContextualLinkAdded', function (event, data) { + // Bind Ajax behaviors to all items showing the class. + // @todo Fix contextual links to work with use-ajax links in + // https://www.drupal.org/node/2764931. + Drupal.attachBehaviors(data.$el[0]); + + // Bind a listener to all 'Quick edit' links for blocks + // Click "Edit" button in toolbar to force Contextual Edit which starts + // Settings Tray edit mode also. + data.$el.find(blockConfigureSelector) + .on('click.outsidein', function () { + if (!isInEditMode()) { + $(toggleEditSelector).trigger('click').trigger('click.outside_in'); + } + // Always disable QuickEdit regardless of whether "EditMode" was just enabled. + disableQuickEdit(); + }); + }); + + $(document).on('keyup.outsidein', function (e) { + if (isInEditMode() && e.keyCode === 27) { + Drupal.announce( + Drupal.t('Exited edit mode.') + ); + toggleEditMode(); + } + }); + + /** + * Gets all items that should be toggled with class during edit mode. + * + * @return {jQuery} + * Items that should be toggled. + */ + function getItemsToToggle() { + return $(itemsToToggleSelector).not(contextualItemsSelector); + } + + /** + * Helper to check the state of the outside-in mode. + * + * @todo don't use a class for this. + * + * @return {boolean} + * State of the outside-in edit mode. + */ + function isInEditMode() { + return $('#toolbar-bar').hasClass('js-outside-in-edit-mode'); + } + + /** + * Helper to toggle Edit mode. + */ + function toggleEditMode() { + setEditModeState(!isInEditMode()); + } + + /** + * Prevent default click events except contextual links. + * + * In edit mode the default action of click events is suppressed. + * + * @param {jQuery.Event} event + * The click event. + */ + function preventClick(event) { + // Do not prevent contextual links. + if ($(event.target).closest('.contextual-links').length) { + return; + } + event.preventDefault(); + } + + /** + * Close any active toolbar tray before entering edit mode. + */ + function closeToolbarTrays() { + $(Drupal.toolbar.models.toolbarModel.get('activeTab')).trigger('click'); + } + + /** + * Disables the QuickEdit module editor if open. + */ + function disableQuickEdit() { + $('.quickedit-toolbar button.action-cancel').trigger('click'); + } + + /** + * Closes/removes off-canvas. + */ + function closeOffCanvas() { + $('.ui-dialog-off-canvas .ui-dialog-titlebar-close').trigger('click'); + } + + /** + * Helper to switch edit mode state. + * + * @param {boolean} editMode + * True enable edit mode, false disable edit mode. + */ + function setEditModeState(editMode) { + if (!document.querySelector('[data-off-canvas-main-canvas]')) { + throw new Error('data-off-canvas-main-canvas is missing from outside-in-page-wrapper.html.twig'); + } + editMode = !!editMode; + var $editButton = $(toggleEditSelector); + var $editables; + // Turn on edit mode. + if (editMode) { + $editButton.text(Drupal.t('Editing')); + closeToolbarTrays(); + + $editables = $('[data-drupal-outsidein="editable"]').once('outsidein'); + if ($editables.length) { + // Use event capture to prevent clicks on links. + document.querySelector('[data-off-canvas-main-canvas]').addEventListener('click', preventClick, true); + + // When a click occurs try and find the outside-in edit link + // and click it. + $editables + .not(contextualItemsSelector) + .on('click.outsidein', function (e) { + // Contextual links are allowed to function in Edit mode. + if ($(e.target).closest('.contextual').length || !localStorage.getItem('Drupal.contextualToolbar.isViewing')) { + return; + } + $(e.currentTarget).find(blockConfigureSelector).trigger('click'); + disableQuickEdit(); + }); + $(quickEditItemSelector) + .not(contextualItemsSelector) + .on('click.outsidein', function (e) { + // For all non-contextual links or the contextual QuickEdit link close the off-canvas dialog. + if (!$(e.target).parent().hasClass('contextual') || $(e.target).parent().hasClass('quickedit')) { + closeOffCanvas(); + } + // Do not trigger if target is quick edit link to avoid loop. + if ($(e.target).parent().hasClass('contextual') || $(e.target).parent().hasClass('quickedit')) { + return; + } + $(e.currentTarget).find('li.quickedit a').trigger('click'); + }); + } + } + // Disable edit mode. + else { + $editables = $('[data-drupal-outsidein="editable"]').removeOnce('outsidein'); + if ($editables.length) { + document.querySelector('[data-off-canvas-main-canvas]').removeEventListener('click', preventClick, true); + $editables.off('.outsidein'); + $(quickEditItemSelector).off('.outsidein'); + } + + $editButton.text(Drupal.t('Edit')); + closeOffCanvas(); + disableQuickEdit(); + } + getItemsToToggle().toggleClass('js-outside-in-edit-mode', editMode); + $('.edit-mode-inactive').toggleClass('visually-hidden', editMode); + } + + /** + * Attaches contextual's edit toolbar tab behavior. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches contextual toolbar behavior on a contextualToolbar-init event. + */ + Drupal.behaviors.outsideInEdit = { + attach: function () { + var editMode = localStorage.getItem('Drupal.contextualToolbar.isViewing') === 'false'; + if (editMode) { + setEditModeState(true); + } + } + }; + + /** + * Toggle the js-outside-edit-mode class on items that we want to disable while in edit mode. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Toggle the js-outside-edit-mode class. + */ + Drupal.behaviors.toggleEditMode = { + attach: function () { + + $(toggleEditSelector).once('outsidein').on('click.outsidein', toggleEditMode); + + var search = Drupal.ajax.WRAPPER_FORMAT + '=drupal_dialog'; + var replace = Drupal.ajax.WRAPPER_FORMAT + '=drupal_dialog_off_canvas'; + // Loop through all Ajax links and change the format to dialog-off-canvas when + // needed. + Drupal.ajax.instances + .filter(function (instance) { + var hasElement = instance && !!instance.element; + var rendererOffCanvas = false; + var wrapperOffCanvas = false; + if (hasElement) { + rendererOffCanvas = $(instance.element).attr('data-dialog-renderer') === 'off_canvas'; + wrapperOffCanvas = instance.options.url.indexOf('drupal_dialog_off_canvas') === -1; + } + return hasElement && rendererOffCanvas && wrapperOffCanvas; + }) + .forEach(function (instance) { + // @todo Move logic for data-dialog-renderer attribute into ajax.js + // https://www.drupal.org/node/2784443 + instance.options.url = instance.options.url.replace(search, replace); + // Check to make sure existing dialogOptions aren't overridden. + if (!('dialogOptions' in instance.options.data)) { + instance.options.data.dialogOptions = {}; + } + instance.options.data.dialogOptions.outsideInActiveEditableId = $(instance.element).parents('.outside-in-editable').attr('id'); + instance.progress = {type: 'fullscreen'}; + }); + } + }; + + // Manage Active editable class on opening and closing of the dialog. + $(window).on({ + 'dialog:beforecreate': function (event, dialog, $element, settings) { + if ($element.is('#drupal-off-canvas')) { + $('body .outside-in-active-editable').removeClass('outside-in-active-editable'); + var $activeElement = $('#' + settings.outsideInActiveEditableId); + if ($activeElement.length) { + $activeElement.addClass('outside-in-active-editable'); + settings.dialogClass += ' ui-dialog-outside-in'; + } + } + }, + 'dialog:beforeclose': function (event, dialog, $element) { + if ($element.is('#drupal-off-canvas')) { + $('body .outside-in-active-editable').removeClass('outside-in-active-editable'); + } + } + }); + +})(jQuery, Drupal); diff --git a/core/modules/outside_in/js/outside_in.js b/core/modules/outside_in/js/outside_in.js index eea9075cab52..a28bd81406c3 100644 --- a/core/modules/outside_in/js/outside_in.js +++ b/core/modules/outside_in/js/outside_in.js @@ -1,7 +1,10 @@ /** - * @file - * Drupal's Settings Tray library. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/outside_in/js/outside_in.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { @@ -13,116 +16,56 @@ var contextualItemsSelector = '[data-contextual-id] a, [data-contextual-id] button'; var quickEditItemSelector = '[data-quickedit-entity-id]'; - /** - * Reacts to contextual links being added. - * - * @param {jQuery.Event} event - * The `drupalContextualLinkAdded` event. - * @param {object} data - * An object containing the data relevant to the event. - * - * @listens event:drupalContextualLinkAdded - */ $(document).on('drupalContextualLinkAdded', function (event, data) { - // Bind Ajax behaviors to all items showing the class. - // @todo Fix contextual links to work with use-ajax links in - // https://www.drupal.org/node/2764931. Drupal.attachBehaviors(data.$el[0]); - // Bind a listener to all 'Quick edit' links for blocks - // Click "Edit" button in toolbar to force Contextual Edit which starts - // Settings Tray edit mode also. - data.$el.find(blockConfigureSelector) - .on('click.outsidein', function () { - if (!isInEditMode()) { - $(toggleEditSelector).trigger('click').trigger('click.outside_in'); - } - // Always disable QuickEdit regardless of whether "EditMode" was just enabled. - disableQuickEdit(); - }); + data.$el.find(blockConfigureSelector).on('click.outsidein', function () { + if (!isInEditMode()) { + $(toggleEditSelector).trigger('click').trigger('click.outside_in'); + } + + disableQuickEdit(); + }); }); $(document).on('keyup.outsidein', function (e) { if (isInEditMode() && e.keyCode === 27) { - Drupal.announce( - Drupal.t('Exited edit mode.') - ); + Drupal.announce(Drupal.t('Exited edit mode.')); toggleEditMode(); } }); - /** - * Gets all items that should be toggled with class during edit mode. - * - * @return {jQuery} - * Items that should be toggled. - */ function getItemsToToggle() { return $(itemsToToggleSelector).not(contextualItemsSelector); } - /** - * Helper to check the state of the outside-in mode. - * - * @todo don't use a class for this. - * - * @return {boolean} - * State of the outside-in edit mode. - */ function isInEditMode() { return $('#toolbar-bar').hasClass('js-outside-in-edit-mode'); } - /** - * Helper to toggle Edit mode. - */ function toggleEditMode() { setEditModeState(!isInEditMode()); } - /** - * Prevent default click events except contextual links. - * - * In edit mode the default action of click events is suppressed. - * - * @param {jQuery.Event} event - * The click event. - */ function preventClick(event) { - // Do not prevent contextual links. if ($(event.target).closest('.contextual-links').length) { return; } event.preventDefault(); } - /** - * Close any active toolbar tray before entering edit mode. - */ function closeToolbarTrays() { $(Drupal.toolbar.models.toolbarModel.get('activeTab')).trigger('click'); } - /** - * Disables the QuickEdit module editor if open. - */ function disableQuickEdit() { $('.quickedit-toolbar button.action-cancel').trigger('click'); } - /** - * Closes/removes off-canvas. - */ function closeOffCanvas() { $('.ui-dialog-off-canvas .ui-dialog-titlebar-close').trigger('click'); } - /** - * Helper to switch edit mode state. - * - * @param {boolean} editMode - * True enable edit mode, false disable edit mode. - */ function setEditModeState(editMode) { if (!document.querySelector('[data-off-canvas-main-canvas]')) { throw new Error('data-off-canvas-main-canvas is missing from outside-in-page-wrapper.html.twig'); @@ -130,70 +73,51 @@ editMode = !!editMode; var $editButton = $(toggleEditSelector); var $editables; - // Turn on edit mode. + if (editMode) { $editButton.text(Drupal.t('Editing')); closeToolbarTrays(); $editables = $('[data-drupal-outsidein="editable"]').once('outsidein'); if ($editables.length) { - // Use event capture to prevent clicks on links. document.querySelector('[data-off-canvas-main-canvas]').addEventListener('click', preventClick, true); - // When a click occurs try and find the outside-in edit link - // and click it. - $editables - .not(contextualItemsSelector) - .on('click.outsidein', function (e) { - // Contextual links are allowed to function in Edit mode. - if ($(e.target).closest('.contextual').length || !localStorage.getItem('Drupal.contextualToolbar.isViewing')) { - return; - } - $(e.currentTarget).find(blockConfigureSelector).trigger('click'); - disableQuickEdit(); - }); - $(quickEditItemSelector) - .not(contextualItemsSelector) - .on('click.outsidein', function (e) { - // For all non-contextual links or the contextual QuickEdit link close the off-canvas dialog. - if (!$(e.target).parent().hasClass('contextual') || $(e.target).parent().hasClass('quickedit')) { - closeOffCanvas(); - } - // Do not trigger if target is quick edit link to avoid loop. - if ($(e.target).parent().hasClass('contextual') || $(e.target).parent().hasClass('quickedit')) { - return; - } - $(e.currentTarget).find('li.quickedit a').trigger('click'); - }); - } - } - // Disable edit mode. - else { - $editables = $('[data-drupal-outsidein="editable"]').removeOnce('outsidein'); - if ($editables.length) { - document.querySelector('[data-off-canvas-main-canvas]').removeEventListener('click', preventClick, true); - $editables.off('.outsidein'); - $(quickEditItemSelector).off('.outsidein'); + $editables.not(contextualItemsSelector).on('click.outsidein', function (e) { + if ($(e.target).closest('.contextual').length || !localStorage.getItem('Drupal.contextualToolbar.isViewing')) { + return; + } + $(e.currentTarget).find(blockConfigureSelector).trigger('click'); + disableQuickEdit(); + }); + $(quickEditItemSelector).not(contextualItemsSelector).on('click.outsidein', function (e) { + if (!$(e.target).parent().hasClass('contextual') || $(e.target).parent().hasClass('quickedit')) { + closeOffCanvas(); + } + + if ($(e.target).parent().hasClass('contextual') || $(e.target).parent().hasClass('quickedit')) { + return; + } + $(e.currentTarget).find('li.quickedit a').trigger('click'); + }); } + } else { + $editables = $('[data-drupal-outsidein="editable"]').removeOnce('outsidein'); + if ($editables.length) { + document.querySelector('[data-off-canvas-main-canvas]').removeEventListener('click', preventClick, true); + $editables.off('.outsidein'); + $(quickEditItemSelector).off('.outsidein'); + } - $editButton.text(Drupal.t('Edit')); - closeOffCanvas(); - disableQuickEdit(); - } + $editButton.text(Drupal.t('Edit')); + closeOffCanvas(); + disableQuickEdit(); + } getItemsToToggle().toggleClass('js-outside-in-edit-mode', editMode); $('.edit-mode-inactive').toggleClass('visually-hidden', editMode); } - /** - * Attaches contextual's edit toolbar tab behavior. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches contextual toolbar behavior on a contextualToolbar-init event. - */ Drupal.behaviors.outsideInEdit = { - attach: function () { + attach: function attach() { var editMode = localStorage.getItem('Drupal.contextualToolbar.isViewing') === 'false'; if (editMode) { setEditModeState(true); @@ -201,51 +125,37 @@ } }; - /** - * Toggle the js-outside-edit-mode class on items that we want to disable while in edit mode. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Toggle the js-outside-edit-mode class. - */ Drupal.behaviors.toggleEditMode = { - attach: function () { + attach: function attach() { $(toggleEditSelector).once('outsidein').on('click.outsidein', toggleEditMode); var search = Drupal.ajax.WRAPPER_FORMAT + '=drupal_dialog'; var replace = Drupal.ajax.WRAPPER_FORMAT + '=drupal_dialog_off_canvas'; - // Loop through all Ajax links and change the format to dialog-off-canvas when - // needed. - Drupal.ajax.instances - .filter(function (instance) { - var hasElement = instance && !!instance.element; - var rendererOffCanvas = false; - var wrapperOffCanvas = false; - if (hasElement) { - rendererOffCanvas = $(instance.element).attr('data-dialog-renderer') === 'off_canvas'; - wrapperOffCanvas = instance.options.url.indexOf('drupal_dialog_off_canvas') === -1; - } - return hasElement && rendererOffCanvas && wrapperOffCanvas; - }) - .forEach(function (instance) { - // @todo Move logic for data-dialog-renderer attribute into ajax.js - // https://www.drupal.org/node/2784443 - instance.options.url = instance.options.url.replace(search, replace); - // Check to make sure existing dialogOptions aren't overridden. - if (!('dialogOptions' in instance.options.data)) { - instance.options.data.dialogOptions = {}; - } - instance.options.data.dialogOptions.outsideInActiveEditableId = $(instance.element).parents('.outside-in-editable').attr('id'); - instance.progress = {type: 'fullscreen'}; - }); + + Drupal.ajax.instances.filter(function (instance) { + var hasElement = instance && !!instance.element; + var rendererOffCanvas = false; + var wrapperOffCanvas = false; + if (hasElement) { + rendererOffCanvas = $(instance.element).attr('data-dialog-renderer') === 'off_canvas'; + wrapperOffCanvas = instance.options.url.indexOf('drupal_dialog_off_canvas') === -1; + } + return hasElement && rendererOffCanvas && wrapperOffCanvas; + }).forEach(function (instance) { + instance.options.url = instance.options.url.replace(search, replace); + + if (!('dialogOptions' in instance.options.data)) { + instance.options.data.dialogOptions = {}; + } + instance.options.data.dialogOptions.outsideInActiveEditableId = $(instance.element).parents('.outside-in-editable').attr('id'); + instance.progress = { type: 'fullscreen' }; + }); } }; - // Manage Active editable class on opening and closing of the dialog. $(window).on({ - 'dialog:beforecreate': function (event, dialog, $element, settings) { + 'dialog:beforecreate': function dialogBeforecreate(event, dialog, $element, settings) { if ($element.is('#drupal-off-canvas')) { $('body .outside-in-active-editable').removeClass('outside-in-active-editable'); var $activeElement = $('#' + settings.outsideInActiveEditableId); @@ -255,11 +165,10 @@ } } }, - 'dialog:beforeclose': function (event, dialog, $element) { + 'dialog:beforeclose': function dialogBeforeclose(event, dialog, $element) { if ($element.is('#drupal-off-canvas')) { $('body .outside-in-active-editable').removeClass('outside-in-active-editable'); } } }); - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/path/path.es6.js b/core/modules/path/path.es6.js new file mode 100644 index 000000000000..dfa2c052a1af --- /dev/null +++ b/core/modules/path/path.es6.js @@ -0,0 +1,29 @@ +/** + * @file + * Attaches behaviors for the Path module. + */ +(function ($, Drupal) { + + 'use strict'; + + /** + * Behaviors for settings summaries on path edit forms. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behavior on path edit forms. + */ + Drupal.behaviors.pathDetailsSummaries = { + attach: function (context) { + $(context).find('.path-form').drupalSetSummary(function (context) { + var path = $('.js-form-item-path-0-alias input').val(); + + return path ? + Drupal.t('Alias: @alias', {'@alias': path}) : + Drupal.t('No alias'); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/path/path.js b/core/modules/path/path.js index dfa2c052a1af..46ce540916e8 100644 --- a/core/modules/path/path.js +++ b/core/modules/path/path.js @@ -1,29 +1,22 @@ /** - * @file - * Attaches behaviors for the Path module. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/path/path.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + (function ($, Drupal) { 'use strict'; - /** - * Behaviors for settings summaries on path edit forms. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches summary behavior on path edit forms. - */ Drupal.behaviors.pathDetailsSummaries = { - attach: function (context) { + attach: function attach(context) { $(context).find('.path-form').drupalSetSummary(function (context) { var path = $('.js-form-item-path-0-alias input').val(); - return path ? - Drupal.t('Alias: @alias', {'@alias': path}) : - Drupal.t('No alias'); + return path ? Drupal.t('Alias: @alias', { '@alias': path }) : Drupal.t('No alias'); }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/editors/formEditor.es6.js b/core/modules/quickedit/js/editors/formEditor.es6.js new file mode 100644 index 000000000000..374e5c21eba4 --- /dev/null +++ b/core/modules/quickedit/js/editors/formEditor.es6.js @@ -0,0 +1,255 @@ +/** + * @file + * Form-based in-place editor. Works for any field type. + */ + +(function ($, Drupal, _) { + + 'use strict'; + + /** + * @constructor + * + * @augments Drupal.quickedit.EditorView + */ + Drupal.quickedit.editors.form = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.form# */{ + + /** + * Tracks form container DOM element that is used while in-place editing. + * + * @type {jQuery} + */ + $formContainer: null, + + /** + * Holds the {@link Drupal.Ajax} object. + * + * @type {Drupal.Ajax} + */ + formSaveAjax: null, + + /** + * @inheritdoc + * + * @param {object} fieldModel + * The field model that holds the state. + * @param {string} state + * The state to change to. + */ + stateChange: function (fieldModel, state) { + var from = fieldModel.previous('state'); + var to = state; + switch (to) { + case 'inactive': + break; + + case 'candidate': + if (from !== 'inactive') { + this.removeForm(); + } + break; + + case 'highlighted': + break; + + case 'activating': + // If coming from an invalid state, then the form is already loaded. + if (from !== 'invalid') { + this.loadForm(); + } + break; + + case 'active': + break; + + case 'changed': + break; + + case 'saving': + this.save(); + break; + + case 'saved': + break; + + case 'invalid': + this.showValidationErrors(); + break; + } + }, + + /** + * @inheritdoc + * + * @return {object} + * A settings object for the quick edit UI. + */ + getQuickEditUISettings: function () { + return {padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: true}; + }, + + /** + * Loads the form for this field, displays it on top of the actual field. + */ + loadForm: function () { + var fieldModel = this.fieldModel; + + // Generate a DOM-compatible ID for the form container DOM element. + var id = 'quickedit-form-for-' + fieldModel.id.replace(/[\/\[\]]/g, '_'); + + // Render form container. + var $formContainer = this.$formContainer = $(Drupal.theme('quickeditFormContainer', { + id: id, + loadingMsg: Drupal.t('Loading…') + })); + $formContainer + .find('.quickedit-form') + .addClass('quickedit-editable quickedit-highlighted quickedit-editing') + .attr('role', 'dialog'); + + // Insert form container in DOM. + if (this.$el.css('display') === 'inline') { + $formContainer.prependTo(this.$el.offsetParent()); + // Position the form container to render on top of the field's element. + var pos = this.$el.position(); + $formContainer.css('left', pos.left).css('top', pos.top); + } + else { + $formContainer.insertBefore(this.$el); + } + + // Load form, insert it into the form container and attach event handlers. + var formOptions = { + fieldID: fieldModel.get('fieldID'), + $el: this.$el, + nocssjs: false, + // Reset an existing entry for this entity in the PrivateTempStore (if + // any) when loading 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 + // loads a first changed field for an entity that this needs to happen: + // precisely now! + reset: !fieldModel.get('entity').get('inTempStore') + }; + Drupal.quickedit.util.form.load(formOptions, function (form, ajax) { + Drupal.AjaxCommands.prototype.insert(ajax, { + data: form, + selector: '#' + id + ' .placeholder' + }); + + $formContainer + .on('formUpdated.quickedit', ':input', function (event) { + var state = fieldModel.get('state'); + // If the form is in an invalid state, it will persist on the page. + // Set the field to activating so that the user can correct the + // invalid value. + if (state === 'invalid') { + fieldModel.set('state', 'activating'); + } + // Otherwise assume that the fieldModel is in a candidate state and + // set it to changed on formUpdate. + else { + fieldModel.set('state', 'changed'); + } + }) + .on('keypress.quickedit', 'input', function (event) { + if (event.keyCode === 13) { + return false; + } + }); + + // The in-place editor has loaded; change state to 'active'. + fieldModel.set('state', 'active'); + }); + }, + + /** + * Removes the form for this field, detaches behaviors and event handlers. + */ + removeForm: function () { + if (this.$formContainer === null) { + return; + } + + delete this.formSaveAjax; + // Allow form widgets to detach properly. + Drupal.detachBehaviors(this.$formContainer.get(0), null, 'unload'); + this.$formContainer + .off('change.quickedit', ':input') + .off('keypress.quickedit', 'input') + .remove(); + this.$formContainer = null; + }, + + /** + * @inheritdoc + */ + save: function () { + var $formContainer = this.$formContainer; + var $submit = $formContainer.find('.quickedit-form-submit'); + var editorModel = this.model; + var fieldModel = this.fieldModel; + + function cleanUpAjax() { + Drupal.quickedit.util.form.unajaxifySaving(formSaveAjax); + formSaveAjax = null; + } + + // Create an AJAX object for the form associated with the field. + var formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving({ + nocssjs: false, + other_view_modes: fieldModel.findOtherViewModes() + }, $submit); + + // Successfully saved. + formSaveAjax.commands.quickeditFieldFormSaved = function (ajax, response, status) { + cleanUpAjax(); + // First, transition the state to 'saved'. + fieldModel.set('state', 'saved'); + // Second, set the 'htmlForOtherViewModes' attribute, so that when this + // field is rerendered, the change can be propagated to other instances + // of this field, which may be displayed in different view modes. + fieldModel.set('htmlForOtherViewModes', response.other_view_modes); + // Finally, set the 'html' attribute on the field model. This will cause + // the field to be rerendered. + _.defer(function () { + fieldModel.set('html', response.data); + }); + }; + + // Unsuccessfully saved; validation errors. + formSaveAjax.commands.quickeditFieldFormValidationErrors = function (ajax, response, status) { + editorModel.set('validationErrors', response.data); + fieldModel.set('state', 'invalid'); + }; + + // The quickeditFieldForm AJAX command is called upon attempting to save + // the form; Form API will mark which form items have errors, if any. This + // command is invoked only if validation errors exist and then it runs + // before editFieldFormValidationErrors(). + formSaveAjax.commands.quickeditFieldForm = function (ajax, response, status) { + Drupal.AjaxCommands.prototype.insert(ajax, { + data: response.data, + selector: '#' + $formContainer.attr('id') + ' form' + }); + }; + + // Click the form's submit button; the scoped AJAX commands above will + // handle the server's response. + $submit.trigger('click.quickedit'); + }, + + /** + * @inheritdoc + */ + showValidationErrors: function () { + this.$formContainer + .find('.quickedit-form') + .addClass('quickedit-validation-error') + .find('form') + .prepend(this.model.get('validationErrors')); + } + }); + +})(jQuery, Drupal, _); diff --git a/core/modules/quickedit/js/editors/formEditor.js b/core/modules/quickedit/js/editors/formEditor.js index 374e5c21eba4..a60747c58357 100644 --- a/core/modules/quickedit/js/editors/formEditor.js +++ b/core/modules/quickedit/js/editors/formEditor.js @@ -1,42 +1,21 @@ /** - * @file - * Form-based in-place editor. Works for any field type. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/editors/formEditor.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, _) { 'use strict'; - /** - * @constructor - * - * @augments Drupal.quickedit.EditorView - */ - Drupal.quickedit.editors.form = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.form# */{ - - /** - * Tracks form container DOM element that is used while in-place editing. - * - * @type {jQuery} - */ + Drupal.quickedit.editors.form = Drupal.quickedit.EditorView.extend({ $formContainer: null, - /** - * Holds the {@link Drupal.Ajax} object. - * - * @type {Drupal.Ajax} - */ formSaveAjax: null, - /** - * @inheritdoc - * - * @param {object} fieldModel - * The field model that holds the state. - * @param {string} state - * The state to change to. - */ - stateChange: function (fieldModel, state) { + stateChange: function stateChange(fieldModel, state) { var from = fieldModel.previous('state'); var to = state; switch (to) { @@ -53,7 +32,6 @@ break; case 'activating': - // If coming from an invalid state, then the form is already loaded. if (from !== 'invalid') { this.loadForm(); } @@ -78,58 +56,35 @@ } }, - /** - * @inheritdoc - * - * @return {object} - * A settings object for the quick edit UI. - */ - getQuickEditUISettings: function () { - return {padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: true}; + getQuickEditUISettings: function getQuickEditUISettings() { + return { padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: true }; }, - /** - * Loads the form for this field, displays it on top of the actual field. - */ - loadForm: function () { + loadForm: function loadForm() { var fieldModel = this.fieldModel; - // Generate a DOM-compatible ID for the form container DOM element. var id = 'quickedit-form-for-' + fieldModel.id.replace(/[\/\[\]]/g, '_'); - // Render form container. var $formContainer = this.$formContainer = $(Drupal.theme('quickeditFormContainer', { id: id, loadingMsg: Drupal.t('Loading…') })); - $formContainer - .find('.quickedit-form') - .addClass('quickedit-editable quickedit-highlighted quickedit-editing') - .attr('role', 'dialog'); + $formContainer.find('.quickedit-form').addClass('quickedit-editable quickedit-highlighted quickedit-editing').attr('role', 'dialog'); - // Insert form container in DOM. if (this.$el.css('display') === 'inline') { $formContainer.prependTo(this.$el.offsetParent()); - // Position the form container to render on top of the field's element. + var pos = this.$el.position(); $formContainer.css('left', pos.left).css('top', pos.top); - } - else { + } else { $formContainer.insertBefore(this.$el); } - // Load form, insert it into the form container and attach event handlers. var formOptions = { fieldID: fieldModel.get('fieldID'), $el: this.$el, nocssjs: false, - // Reset an existing entry for this entity in the PrivateTempStore (if - // any) when loading 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 - // loads a first changed field for an entity that this needs to happen: - // precisely now! + reset: !fieldModel.get('entity').get('inTempStore') }; Drupal.quickedit.util.form.load(formOptions, function (form, ajax) { @@ -138,54 +93,37 @@ selector: '#' + id + ' .placeholder' }); - $formContainer - .on('formUpdated.quickedit', ':input', function (event) { - var state = fieldModel.get('state'); - // If the form is in an invalid state, it will persist on the page. - // Set the field to activating so that the user can correct the - // invalid value. - if (state === 'invalid') { - fieldModel.set('state', 'activating'); - } - // Otherwise assume that the fieldModel is in a candidate state and - // set it to changed on formUpdate. - else { + $formContainer.on('formUpdated.quickedit', ':input', function (event) { + var state = fieldModel.get('state'); + + if (state === 'invalid') { + fieldModel.set('state', 'activating'); + } else { fieldModel.set('state', 'changed'); } - }) - .on('keypress.quickedit', 'input', function (event) { - if (event.keyCode === 13) { - return false; - } - }); + }).on('keypress.quickedit', 'input', function (event) { + if (event.keyCode === 13) { + return false; + } + }); - // The in-place editor has loaded; change state to 'active'. fieldModel.set('state', 'active'); }); }, - /** - * Removes the form for this field, detaches behaviors and event handlers. - */ - removeForm: function () { + removeForm: function removeForm() { if (this.$formContainer === null) { return; } delete this.formSaveAjax; - // Allow form widgets to detach properly. + Drupal.detachBehaviors(this.$formContainer.get(0), null, 'unload'); - this.$formContainer - .off('change.quickedit', ':input') - .off('keypress.quickedit', 'input') - .remove(); + this.$formContainer.off('change.quickedit', ':input').off('keypress.quickedit', 'input').remove(); this.$formContainer = null; }, - /** - * @inheritdoc - */ - save: function () { + save: function save() { var $formContainer = this.$formContainer; var $submit = $formContainer.find('.quickedit-form-submit'); var editorModel = this.model; @@ -196,38 +134,28 @@ formSaveAjax = null; } - // Create an AJAX object for the form associated with the field. var formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving({ nocssjs: false, other_view_modes: fieldModel.findOtherViewModes() }, $submit); - // Successfully saved. formSaveAjax.commands.quickeditFieldFormSaved = function (ajax, response, status) { cleanUpAjax(); - // First, transition the state to 'saved'. + fieldModel.set('state', 'saved'); - // Second, set the 'htmlForOtherViewModes' attribute, so that when this - // field is rerendered, the change can be propagated to other instances - // of this field, which may be displayed in different view modes. + fieldModel.set('htmlForOtherViewModes', response.other_view_modes); - // Finally, set the 'html' attribute on the field model. This will cause - // the field to be rerendered. + _.defer(function () { fieldModel.set('html', response.data); }); }; - // Unsuccessfully saved; validation errors. formSaveAjax.commands.quickeditFieldFormValidationErrors = function (ajax, response, status) { editorModel.set('validationErrors', response.data); fieldModel.set('state', 'invalid'); }; - // The quickeditFieldForm AJAX command is called upon attempting to save - // the form; Form API will mark which form items have errors, if any. This - // command is invoked only if validation errors exist and then it runs - // before editFieldFormValidationErrors(). formSaveAjax.commands.quickeditFieldForm = function (ajax, response, status) { Drupal.AjaxCommands.prototype.insert(ajax, { data: response.data, @@ -235,21 +163,11 @@ }); }; - // Click the form's submit button; the scoped AJAX commands above will - // handle the server's response. $submit.trigger('click.quickedit'); }, - /** - * @inheritdoc - */ - showValidationErrors: function () { - this.$formContainer - .find('.quickedit-form') - .addClass('quickedit-validation-error') - .find('form') - .prepend(this.model.get('validationErrors')); + showValidationErrors: function showValidationErrors() { + this.$formContainer.find('.quickedit-form').addClass('quickedit-validation-error').find('form').prepend(this.model.get('validationErrors')); } }); - -})(jQuery, Drupal, _); +})(jQuery, Drupal, _); \ No newline at end of file diff --git a/core/modules/quickedit/js/editors/plainTextEditor.es6.js b/core/modules/quickedit/js/editors/plainTextEditor.es6.js new file mode 100644 index 000000000000..71f9b74e58bf --- /dev/null +++ b/core/modules/quickedit/js/editors/plainTextEditor.es6.js @@ -0,0 +1,144 @@ +/** + * @file + * ContentEditable-based in-place editor for plain text content. + */ + +(function ($, _, Drupal) { + + 'use strict'; + + Drupal.quickedit.editors.plain_text = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.plain_text# */{ + + /** + * Stores the textual DOM element that is being in-place edited. + */ + $textElement: null, + + /** + * @constructs + * + * @augments Drupal.quickedit.EditorView + * + * @param {object} options + * Options for the plain text editor. + */ + initialize: function (options) { + Drupal.quickedit.EditorView.prototype.initialize.call(this, options); + + var editorModel = this.model; + var fieldModel = this.fieldModel; + + // Store the original value of this field. Necessary for reverting + // changes. + var $textElement; + var $fieldItems = this.$el.find('.quickedit-field'); + if ($fieldItems.length) { + $textElement = this.$textElement = $fieldItems.eq(0); + } + else { + $textElement = this.$textElement = this.$el; + } + editorModel.set('originalValue', $.trim(this.$textElement.text())); + + // Sets the state to 'changed' whenever the value changes. + var previousText = editorModel.get('originalValue'); + $textElement.on('keyup paste', function (event) { + var currentText = $.trim($textElement.text()); + if (previousText !== currentText) { + previousText = currentText; + editorModel.set('currentValue', currentText); + fieldModel.set('state', 'changed'); + } + }); + }, + + /** + * @inheritdoc + * + * @return {jQuery} + * The text element for the plain text editor. + */ + getEditedElement: function () { + return this.$textElement; + }, + + /** + * @inheritdoc + * + * @param {object} fieldModel + * The field model that holds the state. + * @param {string} state + * The state to change to. + * @param {object} options + * State options, if needed by the state change. + */ + stateChange: function (fieldModel, state, options) { + var from = fieldModel.previous('state'); + var to = state; + switch (to) { + case 'inactive': + break; + + case 'candidate': + if (from !== 'inactive') { + this.$textElement.removeAttr('contenteditable'); + } + if (from === 'invalid') { + this.removeValidationErrors(); + } + break; + + case 'highlighted': + break; + + case 'activating': + // Defer updating the field model until the current state change has + // propagated, to not trigger a nested state change event. + _.defer(function () { + fieldModel.set('state', 'active'); + }); + break; + + case 'active': + this.$textElement.attr('contenteditable', 'true'); + break; + + case 'changed': + break; + + case 'saving': + if (from === 'invalid') { + this.removeValidationErrors(); + } + this.save(options); + break; + + case 'saved': + break; + + case 'invalid': + this.showValidationErrors(); + break; + } + }, + + /** + * @inheritdoc + * + * @return {object} + * A settings object for the quick edit UI. + */ + getQuickEditUISettings: function () { + return {padding: true, unifiedToolbar: false, fullWidthToolbar: false, popup: false}; + }, + + /** + * @inheritdoc + */ + revert: function () { + this.$textElement.html(this.model.get('originalValue')); + } + + }); + +})(jQuery, _, Drupal); diff --git a/core/modules/quickedit/js/editors/plainTextEditor.js b/core/modules/quickedit/js/editors/plainTextEditor.js index 71f9b74e58bf..7358c2692dc4 100644 --- a/core/modules/quickedit/js/editors/plainTextEditor.js +++ b/core/modules/quickedit/js/editors/plainTextEditor.js @@ -1,46 +1,33 @@ /** - * @file - * ContentEditable-based in-place editor for plain text content. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/editors/plainTextEditor.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, _, Drupal) { 'use strict'; - Drupal.quickedit.editors.plain_text = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.plain_text# */{ - - /** - * Stores the textual DOM element that is being in-place edited. - */ + Drupal.quickedit.editors.plain_text = Drupal.quickedit.EditorView.extend({ $textElement: null, - /** - * @constructs - * - * @augments Drupal.quickedit.EditorView - * - * @param {object} options - * Options for the plain text editor. - */ - initialize: function (options) { + initialize: function initialize(options) { Drupal.quickedit.EditorView.prototype.initialize.call(this, options); var editorModel = this.model; var fieldModel = this.fieldModel; - // Store the original value of this field. Necessary for reverting - // changes. var $textElement; var $fieldItems = this.$el.find('.quickedit-field'); if ($fieldItems.length) { $textElement = this.$textElement = $fieldItems.eq(0); - } - else { + } else { $textElement = this.$textElement = this.$el; } editorModel.set('originalValue', $.trim(this.$textElement.text())); - // Sets the state to 'changed' whenever the value changes. var previousText = editorModel.get('originalValue'); $textElement.on('keyup paste', function (event) { var currentText = $.trim($textElement.text()); @@ -52,27 +39,11 @@ }); }, - /** - * @inheritdoc - * - * @return {jQuery} - * The text element for the plain text editor. - */ - getEditedElement: function () { + getEditedElement: function getEditedElement() { return this.$textElement; }, - /** - * @inheritdoc - * - * @param {object} fieldModel - * The field model that holds the state. - * @param {string} state - * The state to change to. - * @param {object} options - * State options, if needed by the state change. - */ - stateChange: function (fieldModel, state, options) { + stateChange: function stateChange(fieldModel, state, options) { var from = fieldModel.previous('state'); var to = state; switch (to) { @@ -92,8 +63,6 @@ break; case 'activating': - // Defer updating the field model until the current state change has - // propagated, to not trigger a nested state change event. _.defer(function () { fieldModel.set('state', 'active'); }); @@ -122,23 +91,13 @@ } }, - /** - * @inheritdoc - * - * @return {object} - * A settings object for the quick edit UI. - */ - getQuickEditUISettings: function () { - return {padding: true, unifiedToolbar: false, fullWidthToolbar: false, popup: false}; + getQuickEditUISettings: function getQuickEditUISettings() { + return { padding: true, unifiedToolbar: false, fullWidthToolbar: false, popup: false }; }, - /** - * @inheritdoc - */ - revert: function () { + revert: function revert() { this.$textElement.html(this.model.get('originalValue')); } }); - -})(jQuery, _, Drupal); +})(jQuery, _, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/models/AppModel.es6.js b/core/modules/quickedit/js/models/AppModel.es6.js new file mode 100644 index 000000000000..6c473ac97497 --- /dev/null +++ b/core/modules/quickedit/js/models/AppModel.es6.js @@ -0,0 +1,57 @@ +/** + * @file + * A Backbone Model for the state of the in-place editing application. + * + * @see Drupal.quickedit.AppView + */ + +(function (Backbone, Drupal) { + + 'use strict'; + + /** + * @constructor + * + * @augments Backbone.Model + */ + Drupal.quickedit.AppModel = Backbone.Model.extend(/** @lends Drupal.quickedit.AppModel# */{ + + /** + * @type {object} + * + * @prop {Drupal.quickedit.FieldModel} highlightedField + * @prop {Drupal.quickedit.FieldModel} activeField + * @prop {Drupal.dialog~dialogDefinition} activeModal + */ + defaults: /** @lends Drupal.quickedit.AppModel# */{ + + /** + * The currently state='highlighted' Drupal.quickedit.FieldModel, if any. + * + * @type {Drupal.quickedit.FieldModel} + * + * @see Drupal.quickedit.FieldModel.states + */ + highlightedField: null, + + /** + * The currently state = 'active' Drupal.quickedit.FieldModel, if any. + * + * @type {Drupal.quickedit.FieldModel} + * + * @see Drupal.quickedit.FieldModel.states + */ + activeField: null, + + /** + * Reference to a {@link Drupal.dialog} instance if a state change + * requires confirmation. + * + * @type {Drupal.dialog~dialogDefinition} + */ + activeModal: null + } + + }); + +}(Backbone, Drupal)); diff --git a/core/modules/quickedit/js/models/AppModel.js b/core/modules/quickedit/js/models/AppModel.js index 6c473ac97497..7abc0d9b0ad8 100644 --- a/core/modules/quickedit/js/models/AppModel.js +++ b/core/modules/quickedit/js/models/AppModel.js @@ -1,57 +1,23 @@ /** - * @file - * A Backbone Model for the state of the in-place editing application. - * - * @see Drupal.quickedit.AppView - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/models/AppModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Backbone, Drupal) { 'use strict'; - /** - * @constructor - * - * @augments Backbone.Model - */ - Drupal.quickedit.AppModel = Backbone.Model.extend(/** @lends Drupal.quickedit.AppModel# */{ - - /** - * @type {object} - * - * @prop {Drupal.quickedit.FieldModel} highlightedField - * @prop {Drupal.quickedit.FieldModel} activeField - * @prop {Drupal.dialog~dialogDefinition} activeModal - */ - defaults: /** @lends Drupal.quickedit.AppModel# */{ - - /** - * The currently state='highlighted' Drupal.quickedit.FieldModel, if any. - * - * @type {Drupal.quickedit.FieldModel} - * - * @see Drupal.quickedit.FieldModel.states - */ + Drupal.quickedit.AppModel = Backbone.Model.extend({ + defaults: { highlightedField: null, - /** - * The currently state = 'active' Drupal.quickedit.FieldModel, if any. - * - * @type {Drupal.quickedit.FieldModel} - * - * @see Drupal.quickedit.FieldModel.states - */ activeField: null, - /** - * Reference to a {@link Drupal.dialog} instance if a state change - * requires confirmation. - * - * @type {Drupal.dialog~dialogDefinition} - */ activeModal: null } }); - -}(Backbone, Drupal)); +})(Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/models/BaseModel.es6.js b/core/modules/quickedit/js/models/BaseModel.es6.js new file mode 100644 index 000000000000..7579b6f03b0d --- /dev/null +++ b/core/modules/quickedit/js/models/BaseModel.es6.js @@ -0,0 +1,60 @@ +/** + * @file + * A Backbone Model subclass that enforces validation when calling set(). + */ + +(function (Drupal, Backbone) { + + 'use strict'; + + Drupal.quickedit.BaseModel = Backbone.Model.extend(/** @lends Drupal.quickedit.BaseModel# */{ + + /** + * @constructs + * + * @augments Backbone.Model + * + * @param {object} options + * Options for the base model- + * + * @return {Drupal.quickedit.BaseModel} + * A quickedit base model. + */ + initialize: function (options) { + this.__initialized = true; + return Backbone.Model.prototype.initialize.call(this, options); + }, + + /** + * Set a value on the model + * + * @param {object|string} key + * The key to set a value for. + * @param {*} val + * The value to set. + * @param {object} [options] + * Options for the model. + * + * @return {*} + * The result of `Backbone.Model.prototype.set` with the specified + * parameters. + */ + set: function (key, val, options) { + if (this.__initialized) { + // Deal with both the "key", value and {key:value}-style arguments. + if (typeof key === 'object') { + key.validate = true; + } + else { + if (!options) { + options = {}; + } + options.validate = true; + } + } + return Backbone.Model.prototype.set.call(this, key, val, options); + } + + }); + +}(Drupal, Backbone)); diff --git a/core/modules/quickedit/js/models/BaseModel.js b/core/modules/quickedit/js/models/BaseModel.js index 7579b6f03b0d..6b882bbe374c 100644 --- a/core/modules/quickedit/js/models/BaseModel.js +++ b/core/modules/quickedit/js/models/BaseModel.js @@ -1,51 +1,27 @@ /** - * @file - * A Backbone Model subclass that enforces validation when calling set(). - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/models/BaseModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ +var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; (function (Drupal, Backbone) { 'use strict'; - Drupal.quickedit.BaseModel = Backbone.Model.extend(/** @lends Drupal.quickedit.BaseModel# */{ - - /** - * @constructs - * - * @augments Backbone.Model - * - * @param {object} options - * Options for the base model- - * - * @return {Drupal.quickedit.BaseModel} - * A quickedit base model. - */ - initialize: function (options) { + Drupal.quickedit.BaseModel = Backbone.Model.extend({ + initialize: function initialize(options) { this.__initialized = true; return Backbone.Model.prototype.initialize.call(this, options); }, - /** - * Set a value on the model - * - * @param {object|string} key - * The key to set a value for. - * @param {*} val - * The value to set. - * @param {object} [options] - * Options for the model. - * - * @return {*} - * The result of `Backbone.Model.prototype.set` with the specified - * parameters. - */ - set: function (key, val, options) { + set: function set(key, val, options) { if (this.__initialized) { - // Deal with both the "key", value and {key:value}-style arguments. - if (typeof key === 'object') { + if ((typeof key === 'undefined' ? 'undefined' : _typeof(key)) === 'object') { key.validate = true; - } - else { + } else { if (!options) { options = {}; } @@ -56,5 +32,4 @@ } }); - -}(Drupal, Backbone)); +})(Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/quickedit/js/models/EditorModel.es6.js b/core/modules/quickedit/js/models/EditorModel.es6.js new file mode 100644 index 000000000000..4b6177bc43fd --- /dev/null +++ b/core/modules/quickedit/js/models/EditorModel.es6.js @@ -0,0 +1,54 @@ +/** + * @file + * A Backbone Model for the state of an in-place editor. + * + * @see Drupal.quickedit.EditorView + */ + +(function (Backbone, Drupal) { + + 'use strict'; + + /** + * @constructor + * + * @augments Backbone.Model + */ + Drupal.quickedit.EditorModel = Backbone.Model.extend(/** @lends Drupal.quickedit.EditorModel# */{ + + /** + * @type {object} + * + * @prop {string} originalValue + * @prop {string} currentValue + * @prop {Array} validationErrors + */ + defaults: /** @lends Drupal.quickedit.EditorModel# */{ + + /** + * Not the full HTML representation of this field, but the "actual" + * original value of the field, stored by the used in-place editor, and + * in a representation that can be chosen by the in-place editor. + * + * @type {string} + */ + originalValue: null, + + /** + * Analogous to originalValue, but the current value. + * + * @type {string} + */ + currentValue: null, + + /** + * Stores any validation errors to be rendered. + * + * @type {Array} + */ + validationErrors: null + } + + }); + +}(Backbone, Drupal)); diff --git a/core/modules/quickedit/js/models/EditorModel.js b/core/modules/quickedit/js/models/EditorModel.js index 4b6177bc43fd..29a0c93a9f50 100644 --- a/core/modules/quickedit/js/models/EditorModel.js +++ b/core/modules/quickedit/js/models/EditorModel.js @@ -1,54 +1,23 @@ /** - * @file - * A Backbone Model for the state of an in-place editor. - * - * @see Drupal.quickedit.EditorView - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/models/EditorModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Backbone, Drupal) { 'use strict'; - /** - * @constructor - * - * @augments Backbone.Model - */ - Drupal.quickedit.EditorModel = Backbone.Model.extend(/** @lends Drupal.quickedit.EditorModel# */{ - - /** - * @type {object} - * - * @prop {string} originalValue - * @prop {string} currentValue - * @prop {Array} validationErrors - */ - defaults: /** @lends Drupal.quickedit.EditorModel# */{ - - /** - * Not the full HTML representation of this field, but the "actual" - * original value of the field, stored by the used in-place editor, and - * in a representation that can be chosen by the in-place editor. - * - * @type {string} - */ + Drupal.quickedit.EditorModel = Backbone.Model.extend({ + defaults: { originalValue: null, - /** - * Analogous to originalValue, but the current value. - * - * @type {string} - */ currentValue: null, - /** - * Stores any validation errors to be rendered. - * - * @type {Array} - */ validationErrors: null } }); - -}(Backbone, Drupal)); +})(Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/models/EntityModel.es6.js b/core/modules/quickedit/js/models/EntityModel.es6.js new file mode 100644 index 000000000000..f444fbb91768 --- /dev/null +++ b/core/modules/quickedit/js/models/EntityModel.es6.js @@ -0,0 +1,741 @@ +/** + * @file + * A Backbone Model for the state of an in-place editable entity in the DOM. + */ + +(function (_, $, Backbone, Drupal) { + + 'use strict'; + + Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.EntityModel# */{ + + /** + * @type {object} + */ + defaults: /** @lends Drupal.quickedit.EntityModel# */{ + + /** + * The DOM element that represents this entity. + * + * It may seem bizarre to have a DOM element in a Backbone Model, but we + * need to be able to map entities in the DOM to EntityModels in memory. + * + * @type {HTMLElement} + */ + el: null, + + /** + * An entity ID, of the form `<entity type>/<entity ID>` + * + * @example + * "node/1" + * + * @type {string} + */ + entityID: null, + + /** + * An entity instance ID. + * + * The first instance of a specific entity (i.e. with a given entity ID) + * is assigned 0, the second 1, and so on. + * + * @type {number} + */ + entityInstanceID: null, + + /** + * The unique ID of this entity instance on the page, of the form + * `<entity type>/<entity ID>[entity instance ID]` + * + * @example + * "node/1[0]" + * + * @type {string} + */ + id: null, + + /** + * The label of the entity. + * + * @type {string} + */ + label: null, + + /** + * A FieldCollection for all fields of the entity. + * + * @type {Drupal.quickedit.FieldCollection} + * + * @see Drupal.quickedit.FieldCollection + */ + fields: null, + + // The attributes below are stateful. The ones above will never change + // during the life of a EntityModel instance. + + /** + * Indicates whether this entity is currently being edited in-place. + * + * @type {bool} + */ + isActive: false, + + /** + * Whether one or more fields are already been stored in PrivateTempStore. + * + * @type {bool} + */ + inTempStore: false, + + /** + * Indicates whether a "Save" button is necessary or not. + * + * Whether one or more fields have already been stored in PrivateTempStore + * *or* the field that's currently being edited is in the 'changed' or a + * later state. + * + * @type {bool} + */ + isDirty: false, + + /** + * Whether the request to the server has been made to commit this entity. + * + * Used to prevent multiple such requests. + * + * @type {bool} + */ + isCommitting: false, + + /** + * The current processing state of an entity. + * + * @type {string} + */ + state: 'closed', + + /** + * IDs of fields whose new values have been stored in PrivateTempStore. + * + * 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. + * + * @type {Array.<string>} + */ + fieldsInTempStore: [], + + /** + * A flag the tells the application that this EntityModel must be reloaded + * in order to restore the original values to its fields in the client. + * + * @type {bool} + */ + reload: false + }, + + /** + * @constructs + * + * @augments Drupal.quickedit.BaseModel + */ + initialize: function () { + this.set('fields', new Drupal.quickedit.FieldCollection()); + + // Respond to entity state changes. + this.listenTo(this, 'change:state', this.stateChange); + + // The state of the entity is largely dependent on the state of its + // fields. + this.listenTo(this.get('fields'), 'change:state', this.fieldStateChange); + + // Call Drupal.quickedit.BaseModel's initialize() method. + Drupal.quickedit.BaseModel.prototype.initialize.call(this); + }, + + /** + * Updates FieldModels' states when an EntityModel change occurs. + * + * @param {Drupal.quickedit.EntityModel} entityModel + * The entity model + * @param {string} state + * The state of the associated entity. One of + * {@link Drupal.quickedit.EntityModel.states}. + * @param {object} options + * Options for the entity model. + */ + stateChange: function (entityModel, state, options) { + var to = state; + switch (to) { + case 'closed': + this.set({ + isActive: false, + inTempStore: false, + 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 they want to save the entity. + var fields = this.get('fields'); + // For fields that are in an active state, transition them to + // candidate. + fields.chain() + .filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], ['active']).length; + }) + .each(function (fieldModel) { + fieldModel.set('state', 'candidate'); + }); + // For fields that are in a changed state, field values must first be + // stored in PrivateTempStore. + fields.chain() + .filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], Drupal.quickedit.app.changedFieldStates).length; + }) + .each(function (fieldModel) { + fieldModel.set('state', 'saving'); + }); + break; + + case 'deactivating': + var changedFields = this.get('fields') + .filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], ['changed', 'invalid']).length; + }); + // If the entity contains unconfirmed or unsaved changes, return the + // entity to an opened state and ask the user if they would like to + // save the changes or discard the changes. + // 1. One of the fields is in a changed state. The changed field + // might just be a change in the client or it might have been saved + // to tempstore. + // 2. The saved flag is empty and the confirmed flag is empty. If + // the entity has been saved to the server, the fields changed in + // the client are irrelevant. If the changes are confirmed, then + // proceed to set the fields to candidate state. + if ((changedFields.length || this.get('fieldsInTempStore').length) && (!options.saved && !options.confirmed)) { + // Cancel deactivation until the user confirms save or discard. + this.set('state', 'opened', {confirming: true}); + // An action in reaction to state change must be deferred. + _.defer(function () { + Drupal.quickedit.app.confirmEntityDeactivation(entityModel); + }); + } + else { + var invalidFields = this.get('fields') + .filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], ['invalid']).length; + }); + // Indicate if this EntityModel needs to be reloaded in order to + // restore the original values of its fields. + entityModel.set('reload', (this.get('fieldsInTempStore').length || invalidFields.length)); + // 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; + } + }, + + /** + * Updates a Field and Entity model's "inTempStore" when appropriate. + * + * Helper function. + * + * @param {Drupal.quickedit.EntityModel} entityModel + * The model of the entity for which a field's state attribute has + * changed. + * @param {Drupal.quickedit.FieldModel} fieldModel + * The model of the field whose state attribute has changed. + * + * @see Drupal.quickedit.EntityModel#fieldStateChange + */ + _updateInTempStoreAttributes: function (entityModel, fieldModel) { + var current = fieldModel.get('state'); + var previous = fieldModel.previous('state'); + var fieldsInTempStore = entityModel.get('fieldsInTempStore'); + // If the fieldModel changed to the 'saved' state: remember that this + // field was saved to PrivateTempStore. + if (current === 'saved') { + // Mark the entity as saved in PrivateTempStore, so that we can pass the + // proper "reset PrivateTempStore" boolean value when communicating with + // the server. + entityModel.set('inTempStore', true); + // Mark the field as saved in PrivateTempStore, so that visual + // indicators signifying just that may be rendered. + fieldModel.set('inTempStore', true); + // Remember that this field is in PrivateTempStore, restore when + // rerendered. + fieldsInTempStore.push(fieldModel.get('fieldID')); + 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. + else if (current === 'candidate' && previous === 'inactive') { + fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0); + } + }, + + /** + * Reacts to state changes in this entity's fields. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The model of the field whose state attribute changed. + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.FieldModel.states}. + */ + fieldStateChange: function (fieldModel, state) { + var entityModel = this; + var fieldState = state; + // 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.quickedit.app.readyFieldStates + }); + }); + 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 PrivateTempStore, + // 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); + } + else { + this._updateInTempStoreAttributes(entityModel, fieldModel); + } + 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'}); + }); + } + else { + this._updateInTempStoreAttributes(entityModel, fieldModel); + } + + // 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.quickedit.app.readyFieldStates + }; + if (entityModel.set('isCommitting', true, options)) { + entityModel.save({ + success: function () { + entityModel.set({ + state: 'deactivating', + isCommitting: false + }, {saved: true}); + }, + error: function () { + // Reset the "isCommitting" mutex. + entityModel.set('isCommitting', false); + // Change the state back to "opened", to allow the user to hit + // the "Save" button again. + entityModel.set('state', 'opened', {reason: 'networkerror'}); + // Show a modal to inform the user of the network error. + var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', {'@entity-title': entityModel.get('label')}); + Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message); + } + }); + } + 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.quickedit.app.readyFieldStates + }); + }); + 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 {object} options + * An object of options that contains: + * @param {function} [options.success] + * A function to invoke if the entity is successfully saved. + */ + save: function (options) { + var entityModel = this; + + // Create a Drupal.ajax instance to save the entity. + var entitySaverAjax = Drupal.ajax({ + url: Drupal.url('quickedit/entity/' + entityModel.get('entityID')), + error: function () { + // Let the Drupal.quickedit.EntityModel Backbone model's error() + // method handle errors. + options.error.call(entityModel); + } + }); + // Entity saved successfully. + entitySaverAjax.commands.quickeditEntitySaved = function (ajax, response, status) { + // All fields have been moved from PrivateTempStore 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', []); + + // Invoke the optional success callback. + if (options.success) { + options.success.call(entityModel); + } + }; + // Trigger the AJAX request, which will will return the + // quickeditEntitySaved AJAX command to which we then react. + entitySaverAjax.execute(); + }, + + /** + * Validate the entity model. + * + * @param {object} attrs + * The attributes changes in the save or set call. + * @param {object} options + * An object with the following option: + * @param {string} [options.reason] + * A string that conveys a particular reason to allow for an exceptional + * state change. + * @param {Array} options.accept-field-states + * 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. + * + * @return {string} + * A string to say something about the state of the entity model. + */ + 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 'isCommitting is a mutex, hence only changes are allowed'; + } + }, + + /** + * Checks if a state change can be accepted. + * + * @param {string} from + * From state. + * @param {string} to + * To state. + * @param {object} context + * Context for the check. + * @param {string} context.reason + * The reason for the state change. + * @param {bool} context.confirming + * Whether context is confirming or not. + * + * @return {bool} + * Whether the state change is accepted or not. + * + * @see Drupal.quickedit.AppView#acceptEditorStateChange + */ + _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, or to hit the + // "Save" button again after a server/network error. + else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) { + accept = true; + } + // Allow: deactivating -> opened. + // Necessary to be able to confirm changes with the user. + else if (from === 'deactivating' && to === 'opened' && context.confirming) { + accept = true; + } + // Allow: opened -> deactivating. + // Necessary to be able to stop editing. + else if (from === 'opened' && to === 'deactivating' && context.confirmed) { + accept = true; + } + } + + return accept; + }, + + /** + * Checks if fields have acceptable states. + * + * @param {Array} acceptedFieldStates + * An array of acceptable field states to check for. + * + * @return {bool} + * Whether the fields have an acceptable state. + * + * @see Drupal.quickedit.EntityModel#validate + */ + _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; + }, + + /** + * Destroys the entity model. + * + * @param {object} options + * Options for the entity model. + */ + destroy: function (options) { + Drupal.quickedit.BaseModel.prototype.destroy.call(this, options); + + this.stopListening(); + + // Destroy all fields of this entity. + this.get('fields').reset(); + }, + + /** + * @inheritdoc + */ + sync: function () { + // We don't use REST updates to sync. + return; + } + + }, /** @lends Drupal.quickedit.EntityModel */{ + + /** + * Sequence of all possible states an entity can be in during quickediting. + * + * @type {Array.<string>} + */ + 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 + // PrivateTempStore, 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 + // PrivateTempStore, save them to PrivateTempStore, 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 + // PrivateTempStore 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 {@link Drupal.quickedit.EntityModel.states}. + * @param {string} to + * One of {@link Drupal.quickedit.EntityModel.states}. + * + * @return {bool} + * Whether the 'from' state comes before the 'to' state. + */ + followsStateSequence: function (from, to) { + return _.indexOf(this.states, from) < _.indexOf(this.states, to); + } + + }); + + /** + * @constructor + * + * @augments Backbone.Collection + */ + Drupal.quickedit.EntityCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.EntityCollection# */{ + + /** + * @type {Drupal.quickedit.EntityModel} + */ + model: Drupal.quickedit.EntityModel + }); + +}(_, jQuery, Backbone, Drupal)); diff --git a/core/modules/quickedit/js/models/EntityModel.js b/core/modules/quickedit/js/models/EntityModel.js index f444fbb91768..ebea7a36b0c6 100644 --- a/core/modules/quickedit/js/models/EntityModel.js +++ b/core/modules/quickedit/js/models/EntityModel.js @@ -1,172 +1,55 @@ /** - * @file - * A Backbone Model for the state of an in-place editable entity in the DOM. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/models/EntityModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (_, $, Backbone, Drupal) { 'use strict'; - Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.EntityModel# */{ - - /** - * @type {object} - */ - defaults: /** @lends Drupal.quickedit.EntityModel# */{ - - /** - * The DOM element that represents this entity. - * - * It may seem bizarre to have a DOM element in a Backbone Model, but we - * need to be able to map entities in the DOM to EntityModels in memory. - * - * @type {HTMLElement} - */ + Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend({ + defaults: { el: null, - /** - * An entity ID, of the form `<entity type>/<entity ID>` - * - * @example - * "node/1" - * - * @type {string} - */ entityID: null, - /** - * An entity instance ID. - * - * The first instance of a specific entity (i.e. with a given entity ID) - * is assigned 0, the second 1, and so on. - * - * @type {number} - */ entityInstanceID: null, - /** - * The unique ID of this entity instance on the page, of the form - * `<entity type>/<entity ID>[entity instance ID]` - * - * @example - * "node/1[0]" - * - * @type {string} - */ id: null, - /** - * The label of the entity. - * - * @type {string} - */ label: null, - /** - * A FieldCollection for all fields of the entity. - * - * @type {Drupal.quickedit.FieldCollection} - * - * @see Drupal.quickedit.FieldCollection - */ fields: null, - // The attributes below are stateful. The ones above will never change - // during the life of a EntityModel instance. - - /** - * Indicates whether this entity is currently being edited in-place. - * - * @type {bool} - */ isActive: false, - /** - * Whether one or more fields are already been stored in PrivateTempStore. - * - * @type {bool} - */ inTempStore: false, - /** - * Indicates whether a "Save" button is necessary or not. - * - * Whether one or more fields have already been stored in PrivateTempStore - * *or* the field that's currently being edited is in the 'changed' or a - * later state. - * - * @type {bool} - */ isDirty: false, - /** - * Whether the request to the server has been made to commit this entity. - * - * Used to prevent multiple such requests. - * - * @type {bool} - */ isCommitting: false, - /** - * The current processing state of an entity. - * - * @type {string} - */ state: 'closed', - /** - * IDs of fields whose new values have been stored in PrivateTempStore. - * - * 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. - * - * @type {Array.<string>} - */ fieldsInTempStore: [], - /** - * A flag the tells the application that this EntityModel must be reloaded - * in order to restore the original values to its fields in the client. - * - * @type {bool} - */ reload: false }, - /** - * @constructs - * - * @augments Drupal.quickedit.BaseModel - */ - initialize: function () { + initialize: function initialize() { this.set('fields', new Drupal.quickedit.FieldCollection()); - // Respond to entity state changes. this.listenTo(this, 'change:state', this.stateChange); - // The state of the entity is largely dependent on the state of its - // fields. this.listenTo(this.get('fields'), 'change:state', this.fieldStateChange); - // Call Drupal.quickedit.BaseModel's initialize() method. Drupal.quickedit.BaseModel.prototype.initialize.call(this); }, - /** - * Updates FieldModels' states when an EntityModel change occurs. - * - * @param {Drupal.quickedit.EntityModel} entityModel - * The entity model - * @param {string} state - * The state of the associated entity. One of - * {@link Drupal.quickedit.EntityModel.states}. - * @param {object} options - * Options for the entity model. - */ - stateChange: function (entityModel, state, options) { + stateChange: function stateChange(entityModel, state, options) { var to = state; switch (to) { case 'closed': @@ -181,81 +64,53 @@ 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 they want to save the entity. var fields = this.get('fields'); - // For fields that are in an active state, transition them to - // candidate. - fields.chain() - .filter(function (fieldModel) { - return _.intersection([fieldModel.get('state')], ['active']).length; - }) - .each(function (fieldModel) { - fieldModel.set('state', 'candidate'); - }); - // For fields that are in a changed state, field values must first be - // stored in PrivateTempStore. - fields.chain() - .filter(function (fieldModel) { - return _.intersection([fieldModel.get('state')], Drupal.quickedit.app.changedFieldStates).length; - }) - .each(function (fieldModel) { - fieldModel.set('state', 'saving'); - }); + + fields.chain().filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], ['active']).length; + }).each(function (fieldModel) { + fieldModel.set('state', 'candidate'); + }); + + fields.chain().filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], Drupal.quickedit.app.changedFieldStates).length; + }).each(function (fieldModel) { + fieldModel.set('state', 'saving'); + }); break; case 'deactivating': - var changedFields = this.get('fields') - .filter(function (fieldModel) { - return _.intersection([fieldModel.get('state')], ['changed', 'invalid']).length; - }); - // If the entity contains unconfirmed or unsaved changes, return the - // entity to an opened state and ask the user if they would like to - // save the changes or discard the changes. - // 1. One of the fields is in a changed state. The changed field - // might just be a change in the client or it might have been saved - // to tempstore. - // 2. The saved flag is empty and the confirmed flag is empty. If - // the entity has been saved to the server, the fields changed in - // the client are irrelevant. If the changes are confirmed, then - // proceed to set the fields to candidate state. - if ((changedFields.length || this.get('fieldsInTempStore').length) && (!options.saved && !options.confirmed)) { - // Cancel deactivation until the user confirms save or discard. - this.set('state', 'opened', {confirming: true}); - // An action in reaction to state change must be deferred. + var changedFields = this.get('fields').filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], ['changed', 'invalid']).length; + }); + + if ((changedFields.length || this.get('fieldsInTempStore').length) && !options.saved && !options.confirmed) { + this.set('state', 'opened', { confirming: true }); + _.defer(function () { Drupal.quickedit.app.confirmEntityDeactivation(entityModel); }); - } - else { - var invalidFields = this.get('fields') - .filter(function (fieldModel) { - return _.intersection([fieldModel.get('state')], ['invalid']).length; - }); - // Indicate if this EntityModel needs to be reloaded in order to - // restore the original values of its fields. - entityModel.set('reload', (this.get('fieldsInTempStore').length || invalidFields.length)); - // Set all fields to the 'candidate' state. A changed field may have - // to go through confirmation first. + } else { + var invalidFields = this.get('fields').filter(function (fieldModel) { + return _.intersection([fieldModel.get('state')], ['invalid']).length; + }); + + entityModel.set('reload', this.get('fieldsInTempStore').length || invalidFields.length); + 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 { + } else { fieldModel.set('state', 'candidate', options); } }); @@ -263,7 +118,6 @@ break; case 'closing': - // Set all fields to the 'inactive' state. options.reason = 'stop'; this.get('fields').each(function (fieldModel) { fieldModel.set({ @@ -275,82 +129,34 @@ } }, - /** - * Updates a Field and Entity model's "inTempStore" when appropriate. - * - * Helper function. - * - * @param {Drupal.quickedit.EntityModel} entityModel - * The model of the entity for which a field's state attribute has - * changed. - * @param {Drupal.quickedit.FieldModel} fieldModel - * The model of the field whose state attribute has changed. - * - * @see Drupal.quickedit.EntityModel#fieldStateChange - */ - _updateInTempStoreAttributes: function (entityModel, fieldModel) { + _updateInTempStoreAttributes: function _updateInTempStoreAttributes(entityModel, fieldModel) { var current = fieldModel.get('state'); var previous = fieldModel.previous('state'); var fieldsInTempStore = entityModel.get('fieldsInTempStore'); - // If the fieldModel changed to the 'saved' state: remember that this - // field was saved to PrivateTempStore. + if (current === 'saved') { - // Mark the entity as saved in PrivateTempStore, so that we can pass the - // proper "reset PrivateTempStore" boolean value when communicating with - // the server. entityModel.set('inTempStore', true); - // Mark the field as saved in PrivateTempStore, so that visual - // indicators signifying just that may be rendered. + fieldModel.set('inTempStore', true); - // Remember that this field is in PrivateTempStore, restore when - // rerendered. + fieldsInTempStore.push(fieldModel.get('fieldID')); 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. - else if (current === 'candidate' && previous === 'inactive') { - fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0); - } + } else if (current === 'candidate' && previous === 'inactive') { + fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0); + } }, - /** - * Reacts to state changes in this entity's fields. - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * The model of the field whose state attribute changed. - * @param {string} state - * The state of the associated field. One of - * {@link Drupal.quickedit.FieldModel.states}. - */ - fieldStateChange: function (fieldModel, state) { + fieldStateChange: function fieldStateChange(fieldModel, state) { var entityModel = this; var fieldState = state; - // 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.quickedit.app.readyFieldStates @@ -359,57 +165,39 @@ 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 PrivateTempStore, - // 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); - } - else { + } else { this._updateInTempStoreAttributes(entityModel, fieldModel); } 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'}); + entityModel.set('state', 'opened', { reason: 'invalid' }); }); - } - else { + } else { this._updateInTempStoreAttributes(entityModel, fieldModel); } - // 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.quickedit.app.readyFieldStates }; if (entityModel.set('isCommitting', true, options)) { entityModel.save({ - success: function () { + success: function success() { entityModel.set({ state: 'deactivating', isCommitting: false - }, {saved: true}); + }, { saved: true }); }, - error: function () { - // Reset the "isCommitting" mutex. + error: function error() { entityModel.set('isCommitting', false); - // Change the state back to "opened", to allow the user to hit - // the "Save" button again. - entityModel.set('state', 'opened', {reason: 'networkerror'}); - // Show a modal to inform the user of the network error. - var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', {'@entity-title': entityModel.get('label')}); + + entityModel.set('state', 'opened', { reason: 'networkerror' }); + + var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', { '@entity-title': entityModel.get('label') }); Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message); } }); @@ -417,10 +205,6 @@ 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.quickedit.app.readyFieldStates @@ -429,10 +213,6 @@ 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'] @@ -442,179 +222,85 @@ } }, - /** - * Fires an AJAX request to the REST save URL for an entity. - * - * @param {object} options - * An object of options that contains: - * @param {function} [options.success] - * A function to invoke if the entity is successfully saved. - */ - save: function (options) { + save: function save(options) { var entityModel = this; - // Create a Drupal.ajax instance to save the entity. var entitySaverAjax = Drupal.ajax({ url: Drupal.url('quickedit/entity/' + entityModel.get('entityID')), - error: function () { - // Let the Drupal.quickedit.EntityModel Backbone model's error() - // method handle errors. + error: function error() { options.error.call(entityModel); } }); - // Entity saved successfully. + entitySaverAjax.commands.quickeditEntitySaved = function (ajax, response, status) { - // All fields have been moved from PrivateTempStore 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', []); - // Invoke the optional success callback. if (options.success) { options.success.call(entityModel); } }; - // Trigger the AJAX request, which will will return the - // quickeditEntitySaved AJAX command to which we then react. + entitySaverAjax.execute(); }, - /** - * Validate the entity model. - * - * @param {object} attrs - * The attributes changes in the save or set call. - * @param {object} options - * An object with the following option: - * @param {string} [options.reason] - * A string that conveys a particular reason to allow for an exceptional - * state change. - * @param {Array} options.accept-field-states - * 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. - * - * @return {string} - * A string to say something about the state of the entity model. - */ - validate: function (attrs, options) { + validate: function validate(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'; - } + } 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) { + } else if (currentIsCommitting === true && nextIsCommitting === true) { return 'isCommitting is a mutex, hence only changes are allowed'; } }, - /** - * Checks if a state change can be accepted. - * - * @param {string} from - * From state. - * @param {string} to - * To state. - * @param {object} context - * Context for the check. - * @param {string} context.reason - * The reason for the state change. - * @param {bool} context.confirming - * Whether context is confirming or not. - * - * @return {bool} - * Whether the state change is accepted or not. - * - * @see Drupal.quickedit.AppView#acceptEditorStateChange - */ - _acceptStateChange: function (from, to, context) { + _acceptStateChange: function _acceptStateChange(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, or to hit the - // "Save" button again after a server/network error. - else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) { - accept = true; - } - // Allow: deactivating -> opened. - // Necessary to be able to confirm changes with the user. - else if (from === 'deactivating' && to === 'opened' && context.confirming) { - accept = true; - } - // Allow: opened -> deactivating. - // Necessary to be able to stop editing. - else if (from === 'opened' && to === 'deactivating' && context.confirmed) { - accept = true; - } + } else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) { + accept = true; + } else if (from === 'deactivating' && to === 'opened' && context.confirming) { + accept = true; + } else if (from === 'opened' && to === 'deactivating' && context.confirmed) { + accept = true; + } } return accept; }, - /** - * Checks if fields have acceptable states. - * - * @param {Array} acceptedFieldStates - * An array of acceptable field states to check for. - * - * @return {bool} - * Whether the fields have an acceptable state. - * - * @see Drupal.quickedit.EntityModel#validate - */ - _fieldsHaveAcceptableStates: function (acceptedFieldStates) { + _fieldsHaveAcceptableStates: function _fieldsHaveAcceptableStates(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; } @@ -623,119 +309,28 @@ return accept; }, - /** - * Destroys the entity model. - * - * @param {object} options - * Options for the entity model. - */ - destroy: function (options) { + destroy: function destroy(options) { Drupal.quickedit.BaseModel.prototype.destroy.call(this, options); this.stopListening(); - // Destroy all fields of this entity. this.get('fields').reset(); }, - /** - * @inheritdoc - */ - sync: function () { - // We don't use REST updates to sync. + sync: function sync() { return; } - }, /** @lends Drupal.quickedit.EntityModel */{ - - /** - * Sequence of all possible states an entity can be in during quickediting. - * - * @type {Array.<string>} - */ - 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 - // PrivateTempStore, 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 - // PrivateTempStore, save them to PrivateTempStore, 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 - // PrivateTempStore 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 {@link Drupal.quickedit.EntityModel.states}. - * @param {string} to - * One of {@link Drupal.quickedit.EntityModel.states}. - * - * @return {bool} - * Whether the 'from' state comes before the 'to' state. - */ - followsStateSequence: function (from, to) { + }, { + states: ['closed', 'launching', 'opening', 'opened', 'committing', 'deactivating', 'closing'], + + followsStateSequence: function followsStateSequence(from, to) { return _.indexOf(this.states, from) < _.indexOf(this.states, to); } }); - /** - * @constructor - * - * @augments Backbone.Collection - */ - Drupal.quickedit.EntityCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.EntityCollection# */{ - - /** - * @type {Drupal.quickedit.EntityModel} - */ + Drupal.quickedit.EntityCollection = Backbone.Collection.extend({ model: Drupal.quickedit.EntityModel }); - -}(_, jQuery, Backbone, Drupal)); +})(_, jQuery, Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/models/FieldModel.es6.js b/core/modules/quickedit/js/models/FieldModel.es6.js new file mode 100644 index 000000000000..8aeff107d9c1 --- /dev/null +++ b/core/modules/quickedit/js/models/FieldModel.es6.js @@ -0,0 +1,348 @@ +/** + * @file + * A Backbone Model for the state of an in-place editable field in the DOM. + */ + +(function (_, Backbone, Drupal) { + + 'use strict'; + + Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.FieldModel# */{ + + /** + * @type {object} + */ + defaults: /** @lends Drupal.quickedit.FieldModel# */{ + + /** + * The DOM element that represents this field. It may seem bizarre to have + * a DOM element in a Backbone Model, but we need to be able to map fields + * in the DOM to FieldModels in memory. + */ + el: null, + + /** + * A field ID, of the form + * `<entity type>/<id>/<field name>/<language>/<view mode>` + * + * @example + * "node/1/field_tags/und/full" + */ + fieldID: null, + + /** + * The unique ID of this field within its entity instance on the page, of + * the form `<entity type>/<id>/<field name>/<language>/<view + * mode>[entity instance ID]`. + * + * @example + * "node/1/field_tags/und/full[0]" + */ + id: null, + + /** + * A {@link Drupal.quickedit.EntityModel}. Its "fields" attribute, which + * is a FieldCollection, is automatically updated to include this + * FieldModel. + */ + entity: null, + + /** + * This field's metadata as returned by the + * QuickEditController::metadata(). + */ + metadata: null, + + /** + * Callback function for validating changes between states. Receives the + * previous state, new state, context, and a callback. + */ + acceptStateChange: null, + + /** + * A logical field ID, of the form + * `<entity type>/<id>/<field name>/<language>`, i.e. the fieldID without + * the view mode, to be able to identify other instances of the same + * field on the page but rendered in a different view mode. + * + * @example + * "node/1/field_tags/und". + */ + logicalFieldID: null, + + // The attributes below are stateful. The ones above will never change + // during the life of a FieldModel instance. + + /** + * In-place editing state of this field. Defaults to the initial state. + * Possible values: {@link Drupal.quickedit.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 FieldDecorationView.renderChanged() can react to it. + */ + inTempStore: false, + + /** + * The full HTML representation of this field (with the element that has + * the data-quickedit-field-id as the outer element). Used to propagate + * changes from this field to other instances of the same field storage. + */ + html: null, + + /** + * An object containing the full HTML representations (values) of other + * view modes (keys) of this field, for other instances of this field + * displayed in a different view mode. + */ + htmlForOtherViewModes: null + }, + + /** + * State of an in-place editable field in the DOM. + * + * @constructs + * + * @augments Drupal.quickedit.BaseModel + * + * @param {object} options + * Options for the field model. + */ + initialize: function (options) { + // Store the original full HTML representation of this field. + this.set('html', options.el.outerHTML); + + // Enlist field automatically in the associated entity's field collection. + this.get('entity').get('fields').add(this); + + // Automatically generate the logical field ID. + this.set('logicalFieldID', this.get('fieldID').split('/').slice(0, 4).join('/')); + + // Call Drupal.quickedit.BaseModel's initialize() method. + Drupal.quickedit.BaseModel.prototype.initialize.call(this, options); + }, + + /** + * Destroys the field model. + * + * @param {object} options + * Options for the field model. + */ + destroy: function (options) { + if (this.get('state') !== 'inactive') { + throw new Error('FieldModel cannot be destroyed if it is not inactive state.'); + } + Drupal.quickedit.BaseModel.prototype.destroy.call(this, options); + }, + + /** + * @inheritdoc + */ + sync: function () { + // We don't use REST updates to sync. + return; + }, + + /** + * Validate function for the field model. + * + * @param {object} attrs + * The attributes changes in the save or set call. + * @param {object} options + * An object with the following option: + * @param {string} [options.reason] + * A string that conveys a particular reason to allow for an exceptional + * state change. + * @param {Array} options.accept-field-states + * 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. + * + * @return {string} + * A string to say something about the state of the field model. + */ + validate: function (attrs, options) { + var current = this.get('state'); + var next = attrs.state; + if (current !== next) { + // Ensure it's a valid state. + if (_.indexOf(this.constructor.states, next) === -1) { + return '"' + next + '" is an invalid state'; + } + // Check if the acceptStateChange callback accepts it. + if (!this.get('acceptStateChange')(current, next, options, this)) { + return 'state change not accepted'; + } + } + }, + + /** + * Extracts the entity ID from this field's ID. + * + * @return {string} + * An entity ID: a string of the format `<entity type>/<id>`. + */ + getEntityID: function () { + return this.get('fieldID').split('/').slice(0, 2).join('/'); + }, + + /** + * Extracts the view mode ID from this field's ID. + * + * @return {string} + * A view mode ID. + */ + getViewMode: function () { + return this.get('fieldID').split('/').pop(); + }, + + /** + * Find other instances of this field with different view modes. + * + * @return {Array} + * An array containing view mode IDs. + */ + findOtherViewModes: function () { + var currentField = this; + var otherViewModes = []; + Drupal.quickedit.collections.fields + // Find all instances of fields that display the same logical field + // (same entity, same field, just a different instance and maybe a + // different view mode). + .where({logicalFieldID: currentField.get('logicalFieldID')}) + .forEach(function (field) { + // Ignore the current field. + if (field === currentField) { + return; + } + // Also ignore other fields with the same view mode. + else if (field.get('fieldID') === currentField.get('fieldID')) { + return; + } + else { + otherViewModes.push(field.getViewMode()); + } + }); + return otherViewModes; + } + + }, /** @lends Drupal.quickedit.FieldModel */{ + + /** + * Sequence of all possible states a field can be in during quickediting. + * + * @type {Array.<string>} + */ + states: [ + // 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 + // PrivateTempStore. 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 PrivateTempStore. Upon successful 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' + ], + + /** + * Indicates whether the 'from' state comes before the 'to' state. + * + * @param {string} from + * One of {@link Drupal.quickedit.FieldModel.states}. + * @param {string} to + * One of {@link Drupal.quickedit.FieldModel.states}. + * + * @return {bool} + * Whether the 'from' state comes before the 'to' state. + */ + followsStateSequence: function (from, to) { + return _.indexOf(this.states, from) < _.indexOf(this.states, to); + } + + }); + + /** + * @constructor + * + * @augments Backbone.Collection + */ + Drupal.quickedit.FieldCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.FieldCollection */{ + + /** + * @type {Drupal.quickedit.FieldModel} + */ + model: Drupal.quickedit.FieldModel + }); + +}(_, Backbone, Drupal)); diff --git a/core/modules/quickedit/js/models/FieldModel.js b/core/modules/quickedit/js/models/FieldModel.js index 8aeff107d9c1..305b4a381e9a 100644 --- a/core/modules/quickedit/js/models/FieldModel.js +++ b/core/modules/quickedit/js/models/FieldModel.js @@ -1,348 +1,110 @@ /** - * @file - * A Backbone Model for the state of an in-place editable field in the DOM. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/models/FieldModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (_, Backbone, Drupal) { 'use strict'; - Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.FieldModel# */{ - - /** - * @type {object} - */ - defaults: /** @lends Drupal.quickedit.FieldModel# */{ - - /** - * The DOM element that represents this field. It may seem bizarre to have - * a DOM element in a Backbone Model, but we need to be able to map fields - * in the DOM to FieldModels in memory. - */ + Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend({ + defaults: { el: null, - /** - * A field ID, of the form - * `<entity type>/<id>/<field name>/<language>/<view mode>` - * - * @example - * "node/1/field_tags/und/full" - */ fieldID: null, - /** - * The unique ID of this field within its entity instance on the page, of - * the form `<entity type>/<id>/<field name>/<language>/<view - * mode>[entity instance ID]`. - * - * @example - * "node/1/field_tags/und/full[0]" - */ id: null, - /** - * A {@link Drupal.quickedit.EntityModel}. Its "fields" attribute, which - * is a FieldCollection, is automatically updated to include this - * FieldModel. - */ entity: null, - /** - * This field's metadata as returned by the - * QuickEditController::metadata(). - */ metadata: null, - /** - * Callback function for validating changes between states. Receives the - * previous state, new state, context, and a callback. - */ acceptStateChange: null, - /** - * A logical field ID, of the form - * `<entity type>/<id>/<field name>/<language>`, i.e. the fieldID without - * the view mode, to be able to identify other instances of the same - * field on the page but rendered in a different view mode. - * - * @example - * "node/1/field_tags/und". - */ logicalFieldID: null, - // The attributes below are stateful. The ones above will never change - // during the life of a FieldModel instance. - - /** - * In-place editing state of this field. Defaults to the initial state. - * Possible values: {@link Drupal.quickedit.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 FieldDecorationView.renderChanged() can react to it. - */ inTempStore: false, - /** - * The full HTML representation of this field (with the element that has - * the data-quickedit-field-id as the outer element). Used to propagate - * changes from this field to other instances of the same field storage. - */ html: null, - /** - * An object containing the full HTML representations (values) of other - * view modes (keys) of this field, for other instances of this field - * displayed in a different view mode. - */ htmlForOtherViewModes: null }, - /** - * State of an in-place editable field in the DOM. - * - * @constructs - * - * @augments Drupal.quickedit.BaseModel - * - * @param {object} options - * Options for the field model. - */ - initialize: function (options) { - // Store the original full HTML representation of this field. + initialize: function initialize(options) { this.set('html', options.el.outerHTML); - // Enlist field automatically in the associated entity's field collection. this.get('entity').get('fields').add(this); - // Automatically generate the logical field ID. this.set('logicalFieldID', this.get('fieldID').split('/').slice(0, 4).join('/')); - // Call Drupal.quickedit.BaseModel's initialize() method. Drupal.quickedit.BaseModel.prototype.initialize.call(this, options); }, - /** - * Destroys the field model. - * - * @param {object} options - * Options for the field model. - */ - destroy: function (options) { + destroy: function destroy(options) { if (this.get('state') !== 'inactive') { throw new Error('FieldModel cannot be destroyed if it is not inactive state.'); } Drupal.quickedit.BaseModel.prototype.destroy.call(this, options); }, - /** - * @inheritdoc - */ - sync: function () { - // We don't use REST updates to sync. + sync: function sync() { return; }, - /** - * Validate function for the field model. - * - * @param {object} attrs - * The attributes changes in the save or set call. - * @param {object} options - * An object with the following option: - * @param {string} [options.reason] - * A string that conveys a particular reason to allow for an exceptional - * state change. - * @param {Array} options.accept-field-states - * 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. - * - * @return {string} - * A string to say something about the state of the field model. - */ - validate: function (attrs, options) { + validate: function validate(attrs, options) { var current = this.get('state'); var next = attrs.state; if (current !== next) { - // Ensure it's a valid state. if (_.indexOf(this.constructor.states, next) === -1) { return '"' + next + '" is an invalid state'; } - // Check if the acceptStateChange callback accepts it. + if (!this.get('acceptStateChange')(current, next, options, this)) { return 'state change not accepted'; } } }, - /** - * Extracts the entity ID from this field's ID. - * - * @return {string} - * An entity ID: a string of the format `<entity type>/<id>`. - */ - getEntityID: function () { + getEntityID: function getEntityID() { return this.get('fieldID').split('/').slice(0, 2).join('/'); }, - /** - * Extracts the view mode ID from this field's ID. - * - * @return {string} - * A view mode ID. - */ - getViewMode: function () { + getViewMode: function getViewMode() { return this.get('fieldID').split('/').pop(); }, - /** - * Find other instances of this field with different view modes. - * - * @return {Array} - * An array containing view mode IDs. - */ - findOtherViewModes: function () { + findOtherViewModes: function findOtherViewModes() { var currentField = this; var otherViewModes = []; - Drupal.quickedit.collections.fields - // Find all instances of fields that display the same logical field - // (same entity, same field, just a different instance and maybe a - // different view mode). - .where({logicalFieldID: currentField.get('logicalFieldID')}) - .forEach(function (field) { - // Ignore the current field. - if (field === currentField) { - return; - } - // Also ignore other fields with the same view mode. - else if (field.get('fieldID') === currentField.get('fieldID')) { + Drupal.quickedit.collections.fields.where({ logicalFieldID: currentField.get('logicalFieldID') }).forEach(function (field) { + if (field === currentField) { + return; + } else if (field.get('fieldID') === currentField.get('fieldID')) { return; - } - else { + } else { otherViewModes.push(field.getViewMode()); } - }); + }); return otherViewModes; } - }, /** @lends Drupal.quickedit.FieldModel */{ - - /** - * Sequence of all possible states a field can be in during quickediting. - * - * @type {Array.<string>} - */ - states: [ - // 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 - // PrivateTempStore. 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 PrivateTempStore. Upon successful 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' - ], + }, { + states: ['inactive', 'candidate', 'highlighted', 'activating', 'active', 'changed', 'saving', 'saved', 'invalid'], - /** - * Indicates whether the 'from' state comes before the 'to' state. - * - * @param {string} from - * One of {@link Drupal.quickedit.FieldModel.states}. - * @param {string} to - * One of {@link Drupal.quickedit.FieldModel.states}. - * - * @return {bool} - * Whether the 'from' state comes before the 'to' state. - */ - followsStateSequence: function (from, to) { + followsStateSequence: function followsStateSequence(from, to) { return _.indexOf(this.states, from) < _.indexOf(this.states, to); } }); - /** - * @constructor - * - * @augments Backbone.Collection - */ - Drupal.quickedit.FieldCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.FieldCollection */{ - - /** - * @type {Drupal.quickedit.FieldModel} - */ + Drupal.quickedit.FieldCollection = Backbone.Collection.extend({ model: Drupal.quickedit.FieldModel }); - -}(_, Backbone, Drupal)); +})(_, Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/quickedit.es6.js b/core/modules/quickedit/js/quickedit.es6.js new file mode 100644 index 000000000000..40bdd3e60905 --- /dev/null +++ b/core/modules/quickedit/js/quickedit.es6.js @@ -0,0 +1,686 @@ +/** + * @file + * Attaches behavior for the Quick Edit module. + * + * Everything happens asynchronously, to allow for: + * - dynamically rendered contextual links + * - asynchronously retrieved (and cached) per-field in-place editing metadata + * - asynchronous setup of in-place editable field and "Quick edit" link. + * + * To achieve this, there are several queues: + * - fieldsMetadataQueue: fields whose metadata still needs to be fetched. + * - fieldsAvailableQueue: queue of fields whose metadata is known, and for + * which it has been confirmed that the user has permission to edit them. + * However, FieldModels will only be created for them once there's a + * contextual link for their entity: when it's possible to initiate editing. + * - contextualLinksQueue: queue of contextual links on entities for which it + * is not yet known whether the user has permission to edit at >=1 of them. + */ + +(function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) { + + 'use strict'; + + var options = $.extend(drupalSettings.quickedit, + // Merge strings on top of drupalSettings so that they are not mutable. + { + strings: { + quickEdit: Drupal.t('Quick edit') + } + } + ); + + /** + * Tracks fields without metadata. Contains objects with the following keys: + * - DOM el + * - String fieldID + * - String entityID + */ + var fieldsMetadataQueue = []; + + /** + * Tracks fields ready for use. Contains objects with the following keys: + * - DOM el + * - String fieldID + * - String entityID + */ + var fieldsAvailableQueue = []; + + /** + * Tracks contextual links on entities. Contains objects with the following + * keys: + * - String entityID + * - DOM el + * - DOM region + */ + var contextualLinksQueue = []; + + /** + * Tracks how many instances exist for each unique entity. Contains key-value + * pairs: + * - String entityID + * - Number count + */ + var entityInstancesTracker = {}; + + /** + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.quickedit = { + attach: function (context) { + // Initialize the Quick Edit app once per page load. + $('body').once('quickedit-init').each(initQuickEdit); + + // Find all in-place editable fields, if any. + var $fields = $(context).find('[data-quickedit-field-id]').once('quickedit'); + if ($fields.length === 0) { + return; + } + + // Process each entity element: identical entities that appear multiple + // times will get a numeric identifier, starting at 0. + $(context).find('[data-quickedit-entity-id]').once('quickedit').each(function (index, entityElement) { + processEntity(entityElement); + }); + + // 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(). + $fields.each(function (index, fieldElement) { + processField(fieldElement); + }); + + // Entities and fields on the page have been detected, try to set up the + // contextual links for those entities that already have the necessary + // meta- data in the client-side cache. + contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) { + return !initializeEntityContextualLink(contextualLink); + }); + + // Fetch metadata for any fields that are queued to retrieve it. + fetchMissingMetadata(function (fieldElementsWithFreshMetadata) { + // Metadata has been fetched, reprocess fields whose metadata was + // missing. + _.each(fieldElementsWithFreshMetadata, processField); + + // Metadata has been fetched, try to set up more contextual links now. + contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) { + return !initializeEntityContextualLink(contextualLink); + }); + }); + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + deleteContainedModelsAndQueues($(context)); + } + } + }; + + /** + * + * @namespace + */ + Drupal.quickedit = { + + /** + * A {@link Drupal.quickedit.AppView} instance. + */ + app: null, + + /** + * @type {object} + * + * @prop {Array.<Drupal.quickedit.EntityModel>} entities + * @prop {Array.<Drupal.quickedit.FieldModel>} fields + */ + collections: { + // All in-place editable entities (Drupal.quickedit.EntityModel) on the + // page. + entities: null, + // All in-place editable fields (Drupal.quickedit.FieldModel) on the page. + fields: null + }, + + /** + * In-place editors will register themselves in this object. + * + * @namespace + */ + editors: {}, + + /** + * Per-field metadata that indicates whether in-place editing is allowed, + * which in-place editor should be used, etc. + * + * @namespace + */ + metadata: { + + /** + * Check if a field exists in storage. + * + * @param {string} fieldID + * The field id to check. + * + * @return {bool} + * Whether it was found or not. + */ + has: function (fieldID) { + return storage.getItem(this._prefixFieldID(fieldID)) !== null; + }, + + /** + * Add metadata to a field id. + * + * @param {string} fieldID + * The field ID to add data to. + * @param {object} metadata + * Metadata to add. + */ + add: function (fieldID, metadata) { + storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata)); + }, + + /** + * Get a key from a field id. + * + * @param {string} fieldID + * The field ID to check. + * @param {string} [key] + * The key to check. If empty, will return all metadata. + * + * @return {object|*} + * The value for the key, if defined. Otherwise will return all metadata + * for the specified field id. + * + */ + get: function (fieldID, key) { + var metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID))); + return (typeof key === 'undefined') ? metadata : metadata[key]; + }, + + /** + * Prefix the field id. + * + * @param {string} fieldID + * The field id to prefix. + * + * @return {string} + * A prefixed field id. + */ + _prefixFieldID: function (fieldID) { + return 'Drupal.quickedit.metadata.' + fieldID; + }, + + /** + * Unprefix the field id. + * + * @param {string} fieldID + * The field id to unprefix. + * + * @return {string} + * An unprefixed field id. + */ + _unprefixFieldID: function (fieldID) { + // Strip "Drupal.quickedit.metadata.", which is 26 characters long. + return fieldID.substring(26); + }, + + /** + * Intersection calculation. + * + * @param {Array} fieldIDs + * An array of field ids to compare to prefix field id. + * + * @return {Array} + * The intersection found. + */ + intersection: function (fieldIDs) { + var prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID); + var intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage)); + return _.map(intersection, this._unprefixFieldID); + } + } + }; + + // Clear the Quick Edit metadata cache whenever the current user's set of + // permissions changes. + var permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash'); + var permissionsHashValue = storage.getItem(permissionsHashKey); + var permissionsHash = drupalSettings.user.permissionsHash; + if (permissionsHashValue !== permissionsHash) { + if (typeof permissionsHash === 'string') { + _.chain(storage).keys().each(function (key) { + if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') { + storage.removeItem(key); + } + }); + } + storage.setItem(permissionsHashKey, permissionsHash); + } + + /** + * Detect contextual links on entities annotated by quickedit. + * + * Queue contextual links to be processed. + * + * @param {jQuery.Event} event + * The `drupalContextualLinkAdded` event. + * @param {object} data + * An object containing the data relevant to the event. + * + * @listens event:drupalContextualLinkAdded + */ + $(document).on('drupalContextualLinkAdded', function (event, data) { + if (data.$region.is('[data-quickedit-entity-id]')) { + // If the contextual link is cached on the client side, an entity instance + // will not yet have been assigned. So assign one. + if (!data.$region.is('[data-quickedit-entity-instance-id]')) { + data.$region.once('quickedit'); + processEntity(data.$region.get(0)); + } + var contextualLink = { + entityID: data.$region.attr('data-quickedit-entity-id'), + entityInstanceID: data.$region.attr('data-quickedit-entity-instance-id'), + el: data.$el[0], + region: data.$region[0] + }; + // Set up contextual links for this, otherwise queue it to be set up + // later. + if (!initializeEntityContextualLink(contextualLink)) { + contextualLinksQueue.push(contextualLink); + } + } + }); + + /** + * Extracts the entity ID from a field ID. + * + * @param {string} fieldID + * A field ID: a string of the format + * `<entity type>/<id>/<field name>/<language>/<view mode>`. + * + * @return {string} + * An entity ID: a string of the format `<entity type>/<id>`. + */ + function extractEntityID(fieldID) { + return fieldID.split('/').slice(0, 2).join('/'); + } + + /** + * Initialize the Quick Edit app. + * + * @param {HTMLElement} bodyElement + * This document's body element. + */ + function initQuickEdit(bodyElement) { + Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection(); + Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection(); + + // Instantiate AppModel (application state) and AppView, which is the + // controller of the whole in-place editing experience. + Drupal.quickedit.app = new Drupal.quickedit.AppView({ + el: bodyElement, + model: new Drupal.quickedit.AppModel(), + entitiesCollection: Drupal.quickedit.collections.entities, + fieldsCollection: Drupal.quickedit.collections.fields + }); + } + + /** + * Assigns the entity an instance ID. + * + * @param {HTMLElement} entityElement + * A Drupal Entity API entity's DOM element with a data-quickedit-entity-id + * attribute. + */ + function processEntity(entityElement) { + var entityID = entityElement.getAttribute('data-quickedit-entity-id'); + if (!entityInstancesTracker.hasOwnProperty(entityID)) { + entityInstancesTracker[entityID] = 0; + } + else { + entityInstancesTracker[entityID]++; + } + + // Set the calculated entity instance ID for this element. + var entityInstanceID = entityInstancesTracker[entityID]; + entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID); + } + + /** + * Fetch the field's metadata; queue or initialize it (if EntityModel exists). + * + * @param {HTMLElement} fieldElement + * A Drupal Field API field's DOM element with a data-quickedit-field-id + * attribute. + */ + function processField(fieldElement) { + var metadata = Drupal.quickedit.metadata; + var fieldID = fieldElement.getAttribute('data-quickedit-field-id'); + var entityID = extractEntityID(fieldID); + // Figure out the instance ID by looking at the ancestor + // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id + // attribute. + var entityElementSelector = '[data-quickedit-entity-id="' + entityID + '"]'; + var entityElement = $(fieldElement).closest(entityElementSelector); + // In the case of a full entity view page, the entity title is rendered + // outside of "the entity DOM node": it's rendered as the page title. So in + // this case, we find the lowest common parent element (deepest in the tree) + // and consider that the entity element. + if (entityElement.length === 0) { + var $lowestCommonParent = $(entityElementSelector).parents().has(fieldElement).first(); + entityElement = $lowestCommonParent.find(entityElementSelector); + } + var entityInstanceID = entityElement + .get(0) + .getAttribute('data-quickedit-entity-instance-id'); + + // Early-return if metadata for this field is missing. + if (!metadata.has(fieldID)) { + fieldsMetadataQueue.push({ + el: fieldElement, + fieldID: fieldID, + entityID: entityID, + entityInstanceID: entityInstanceID + }); + return; + } + // Early-return if the user is not allowed to in-place edit this field. + if (metadata.get(fieldID, 'access') !== true) { + return; + } + + // If an EntityModel for this field already exists (and hence also a "Quick + // edit" contextual link), then initialize it immediately. + if (Drupal.quickedit.collections.entities.findWhere({entityID: entityID, entityInstanceID: entityInstanceID})) { + initializeField(fieldElement, fieldID, entityID, entityInstanceID); + } + // Otherwise: queue the field. It is now available to be set up when its + // corresponding entity becomes in-place editable. + else { + fieldsAvailableQueue.push({el: fieldElement, fieldID: fieldID, entityID: entityID, entityInstanceID: entityInstanceID}); + } + } + + /** + * Initialize a field; create FieldModel. + * + * @param {HTMLElement} fieldElement + * The field's DOM element. + * @param {string} fieldID + * The field's ID. + * @param {string} entityID + * The field's entity's ID. + * @param {string} entityInstanceID + * The field's entity's instance ID. + */ + function initializeField(fieldElement, fieldID, entityID, entityInstanceID) { + var entity = Drupal.quickedit.collections.entities.findWhere({ + entityID: entityID, + entityInstanceID: entityInstanceID + }); + + $(fieldElement).addClass('quickedit-field'); + + // The FieldModel stores the state of an in-place editable entity field. + var field = new Drupal.quickedit.FieldModel({ + el: fieldElement, + fieldID: fieldID, + id: fieldID + '[' + entity.get('entityInstanceID') + ']', + entity: entity, + metadata: Drupal.quickedit.metadata.get(fieldID), + acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app) + }); + + // Track all fields on the page. + Drupal.quickedit.collections.fields.add(field); + } + + /** + * Fetches metadata for fields whose metadata is missing. + * + * Fields whose metadata is missing are tracked at fieldsMetadataQueue. + * + * @param {function} callback + * A callback function that receives field elements whose metadata will just + * have been fetched. + */ + 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, Drupal.quickedit.metadata.intersection(entityIDs)); + fieldsMetadataQueue = []; + + $.ajax({ + url: Drupal.url('quickedit/metadata'), + type: 'POST', + data: { + 'fields[]': fieldIDs, + 'entities[]': entityIDs + }, + dataType: 'json', + success: function (results) { + // Store the metadata. + _.each(results, function (fieldMetadata, fieldID) { + Drupal.quickedit.metadata.add(fieldID, fieldMetadata); + }); + + callback(fieldElementsWithoutMetadata); + } + }); + } + } + + /** + * Loads missing in-place editor's attachments (JavaScript and CSS files). + * + * Missing in-place editors are those whose fields are actively being used on + * the page but don't have. + * + * @param {function} callback + * Callback function to be called when the missing in-place editors (if any) + * have been inserted into the DOM. i.e. they may still be loading. + */ + function loadMissingEditors(callback) { + var loadedEditors = _.keys(Drupal.quickedit.editors); + var missingEditors = []; + Drupal.quickedit.collections.fields.each(function (fieldModel) { + var metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID')); + if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) { + missingEditors.push(metadata.editor); + // Set a stub, to prevent subsequent calls to loadMissingEditors() from + // loading the same in-place editor again. Loading an in-place editor + // requires talking to a server, to download its JavaScript, then + // executing its JavaScript, and only then its Drupal.quickedit.editors + // entry will be set. + Drupal.quickedit.editors[metadata.editor] = false; + } + }); + missingEditors = _.uniq(missingEditors); + if (missingEditors.length === 0) { + callback(); + return; + } + + // @see https://www.drupal.org/node/2029999. + // Create a Drupal.Ajax instance to load the form. + var loadEditorsAjax = Drupal.ajax({ + url: Drupal.url('quickedit/attachments'), + submit: {'editors[]': missingEditors} + }); + // 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; + 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 any missing attachments. + loadEditorsAjax.execute(); + } + + /** + * Attempts to set up a "Quick edit" link and corresponding EntityModel. + * + * @param {object} contextualLink + * An object with the following properties: + * - String entityID: a Quick Edit entity identifier, e.g. "node/1" or + * "block_content/5". + * - String entityInstanceID: a Quick Edit entity instance identifier, + * e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st + * instance of this entity). + * - DOM el: element pointing to the contextual links placeholder for this + * entity. + * - DOM region: element pointing to the contextual region of this entity. + * + * @return {bool} + * Returns true when a contextual the given contextual link metadata can be + * removed from the queue (either because the contextual link has been set + * up or because it is certain that in-place editing is not allowed for any + * of its fields). Returns false otherwise. + */ + function initializeEntityContextualLink(contextualLink) { + var metadata = Drupal.quickedit.metadata; + // Check if the user has permission to edit at least one of them. + function hasFieldWithPermission(fieldIDs) { + for (var i = 0; i < fieldIDs.length; i++) { + var fieldID = fieldIDs[i]; + if (metadata.get(fieldID, 'access') === true) { + return true; + } + } + return false; + } + + // Checks if the metadata for all given field IDs exists. + function allMetadataExists(fieldIDs) { + return fieldIDs.length === metadata.intersection(fieldIDs).length; + } + + // Find all fields for this entity instance and collect their field IDs. + var fields = _.where(fieldsAvailableQueue, { + entityID: contextualLink.entityID, + entityInstanceID: contextualLink.entityInstanceID + }); + var fieldIDs = _.pluck(fields, 'fieldID'); + + // No fields found yet. + if (fieldIDs.length === 0) { + return false; + } + // The entity for the given contextual link contains at least one field that + // the current user may edit in-place; instantiate EntityModel, + // EntityDecorationView and ContextualLinkView. + else if (hasFieldWithPermission(fieldIDs)) { + var entityModel = new Drupal.quickedit.EntityModel({ + el: contextualLink.region, + entityID: contextualLink.entityID, + entityInstanceID: contextualLink.entityInstanceID, + id: contextualLink.entityID + '[' + contextualLink.entityInstanceID + ']', + label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label') + }); + Drupal.quickedit.collections.entities.add(entityModel); + // Create an EntityDecorationView associated with the root DOM node of the + // entity. + var entityDecorationView = new Drupal.quickedit.EntityDecorationView({ + el: contextualLink.region, + model: entityModel + }); + entityModel.set('entityDecorationView', entityDecorationView); + + // Initialize all queued fields within this entity (creates FieldModels). + _.each(fields, function (field) { + initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID); + }); + fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields); + + // Initialization should only be called once. Use Underscore's once method + // to get a one-time use version of the function. + var initContextualLink = _.once(function () { + var $links = $(contextualLink.el).find('.contextual-links'); + var contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({ + el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links), + model: entityModel, + appModel: Drupal.quickedit.app.model + }, options)); + entityModel.set('contextualLinkView', contextualLinkView); + }); + + // Set up ContextualLinkView after loading any missing in-place editors. + loadMissingEditors(initContextualLink); + + return true; + } + // There was not at least one field that the current user may edit in-place, + // even though the metadata for all fields within this entity is available. + else if (allMetadataExists(fieldIDs)) { + return true; + } + + return false; + } + + /** + * Delete models and queue items that are contained within a given context. + * + * Deletes any contained EntityModels (plus their associated FieldModels and + * ContextualLinkView) and FieldModels, as well as the corresponding queues. + * + * After EntityModels, FieldModels must also be deleted, because it is + * possible in Drupal for a field DOM element to exist outside of the entity + * DOM element, e.g. when viewing the full node, the title of the node is not + * rendered within the node (the entity) but as the page title. + * + * Note: this will not delete an entity that is actively being in-place + * edited. + * + * @param {jQuery} $context + * The context within which to delete. + */ + function deleteContainedModelsAndQueues($context) { + $context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each(function (index, entityElement) { + // Delete entity model. + var entityModel = Drupal.quickedit.collections.entities.findWhere({el: entityElement}); + if (entityModel) { + var contextualLinkView = entityModel.get('contextualLinkView'); + contextualLinkView.undelegateEvents(); + contextualLinkView.remove(); + // Remove the EntityDecorationView. + entityModel.get('entityDecorationView').remove(); + // Destroy the EntityModel; this will also destroy its FieldModels. + entityModel.destroy(); + } + + // Filter queue. + function hasOtherRegion(contextualLink) { + return contextualLink.region !== entityElement; + } + + contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion); + }); + + $context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each(function (index, fieldElement) { + // Delete field models. + Drupal.quickedit.collections.fields.chain() + .filter(function (fieldModel) { return fieldModel.get('el') === fieldElement; }) + .invoke('destroy'); + + // Filter queues. + function hasOtherFieldElement(field) { + return field.el !== fieldElement; + } + + fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement); + fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement); + }); + } + +})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage); diff --git a/core/modules/quickedit/js/quickedit.js b/core/modules/quickedit/js/quickedit.js index 40bdd3e60905..5f888ea817f7 100644 --- a/core/modules/quickedit/js/quickedit.js +++ b/core/modules/quickedit/js/quickedit.js @@ -1,244 +1,99 @@ /** - * @file - * Attaches behavior for the Quick Edit module. - * - * Everything happens asynchronously, to allow for: - * - dynamically rendered contextual links - * - asynchronously retrieved (and cached) per-field in-place editing metadata - * - asynchronous setup of in-place editable field and "Quick edit" link. - * - * To achieve this, there are several queues: - * - fieldsMetadataQueue: fields whose metadata still needs to be fetched. - * - fieldsAvailableQueue: queue of fields whose metadata is known, and for - * which it has been confirmed that the user has permission to edit them. - * However, FieldModels will only be created for them once there's a - * contextual link for their entity: when it's possible to initiate editing. - * - contextualLinksQueue: queue of contextual links on entities for which it - * is not yet known whether the user has permission to edit at >=1 of them. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/quickedit.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) { 'use strict'; - var options = $.extend(drupalSettings.quickedit, - // Merge strings on top of drupalSettings so that they are not mutable. - { - strings: { - quickEdit: Drupal.t('Quick edit') - } + var options = $.extend(drupalSettings.quickedit, { + strings: { + quickEdit: Drupal.t('Quick edit') } - ); - - /** - * Tracks fields without metadata. Contains objects with the following keys: - * - DOM el - * - String fieldID - * - String entityID - */ + }); + var fieldsMetadataQueue = []; - /** - * Tracks fields ready for use. Contains objects with the following keys: - * - DOM el - * - String fieldID - * - String entityID - */ var fieldsAvailableQueue = []; - /** - * Tracks contextual links on entities. Contains objects with the following - * keys: - * - String entityID - * - DOM el - * - DOM region - */ var contextualLinksQueue = []; - /** - * Tracks how many instances exist for each unique entity. Contains key-value - * pairs: - * - String entityID - * - Number count - */ var entityInstancesTracker = {}; - /** - * - * @type {Drupal~behavior} - */ Drupal.behaviors.quickedit = { - attach: function (context) { - // Initialize the Quick Edit app once per page load. + attach: function attach(context) { $('body').once('quickedit-init').each(initQuickEdit); - // Find all in-place editable fields, if any. var $fields = $(context).find('[data-quickedit-field-id]').once('quickedit'); if ($fields.length === 0) { return; } - // Process each entity element: identical entities that appear multiple - // times will get a numeric identifier, starting at 0. $(context).find('[data-quickedit-entity-id]').once('quickedit').each(function (index, entityElement) { processEntity(entityElement); }); - // 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(). $fields.each(function (index, fieldElement) { processField(fieldElement); }); - // Entities and fields on the page have been detected, try to set up the - // contextual links for those entities that already have the necessary - // meta- data in the client-side cache. contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) { return !initializeEntityContextualLink(contextualLink); }); - // Fetch metadata for any fields that are queued to retrieve it. fetchMissingMetadata(function (fieldElementsWithFreshMetadata) { - // Metadata has been fetched, reprocess fields whose metadata was - // missing. _.each(fieldElementsWithFreshMetadata, processField); - // Metadata has been fetched, try to set up more contextual links now. contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) { return !initializeEntityContextualLink(contextualLink); }); }); }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload') { deleteContainedModelsAndQueues($(context)); } } }; - /** - * - * @namespace - */ Drupal.quickedit = { - - /** - * A {@link Drupal.quickedit.AppView} instance. - */ app: null, - /** - * @type {object} - * - * @prop {Array.<Drupal.quickedit.EntityModel>} entities - * @prop {Array.<Drupal.quickedit.FieldModel>} fields - */ collections: { - // All in-place editable entities (Drupal.quickedit.EntityModel) on the - // page. entities: null, - // All in-place editable fields (Drupal.quickedit.FieldModel) on the page. + fields: null }, - /** - * In-place editors will register themselves in this object. - * - * @namespace - */ editors: {}, - /** - * Per-field metadata that indicates whether in-place editing is allowed, - * which in-place editor should be used, etc. - * - * @namespace - */ metadata: { - - /** - * Check if a field exists in storage. - * - * @param {string} fieldID - * The field id to check. - * - * @return {bool} - * Whether it was found or not. - */ - has: function (fieldID) { + has: function has(fieldID) { return storage.getItem(this._prefixFieldID(fieldID)) !== null; }, - /** - * Add metadata to a field id. - * - * @param {string} fieldID - * The field ID to add data to. - * @param {object} metadata - * Metadata to add. - */ - add: function (fieldID, metadata) { + add: function add(fieldID, metadata) { storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata)); }, - /** - * Get a key from a field id. - * - * @param {string} fieldID - * The field ID to check. - * @param {string} [key] - * The key to check. If empty, will return all metadata. - * - * @return {object|*} - * The value for the key, if defined. Otherwise will return all metadata - * for the specified field id. - * - */ - get: function (fieldID, key) { + get: function get(fieldID, key) { var metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID))); - return (typeof key === 'undefined') ? metadata : metadata[key]; + return typeof key === 'undefined' ? metadata : metadata[key]; }, - /** - * Prefix the field id. - * - * @param {string} fieldID - * The field id to prefix. - * - * @return {string} - * A prefixed field id. - */ - _prefixFieldID: function (fieldID) { + _prefixFieldID: function _prefixFieldID(fieldID) { return 'Drupal.quickedit.metadata.' + fieldID; }, - /** - * Unprefix the field id. - * - * @param {string} fieldID - * The field id to unprefix. - * - * @return {string} - * An unprefixed field id. - */ - _unprefixFieldID: function (fieldID) { - // Strip "Drupal.quickedit.metadata.", which is 26 characters long. + _unprefixFieldID: function _unprefixFieldID(fieldID) { return fieldID.substring(26); }, - /** - * Intersection calculation. - * - * @param {Array} fieldIDs - * An array of field ids to compare to prefix field id. - * - * @return {Array} - * The intersection found. - */ - intersection: function (fieldIDs) { + intersection: function intersection(fieldIDs) { var prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID); var intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage)); return _.map(intersection, this._unprefixFieldID); @@ -246,8 +101,6 @@ } }; - // Clear the Quick Edit metadata cache whenever the current user's set of - // permissions changes. var permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash'); var permissionsHashValue = storage.getItem(permissionsHashKey); var permissionsHash = drupalSettings.user.permissionsHash; @@ -262,22 +115,8 @@ storage.setItem(permissionsHashKey, permissionsHash); } - /** - * Detect contextual links on entities annotated by quickedit. - * - * Queue contextual links to be processed. - * - * @param {jQuery.Event} event - * The `drupalContextualLinkAdded` event. - * @param {object} data - * An object containing the data relevant to the event. - * - * @listens event:drupalContextualLinkAdded - */ $(document).on('drupalContextualLinkAdded', function (event, data) { if (data.$region.is('[data-quickedit-entity-id]')) { - // If the contextual link is cached on the client side, an entity instance - // will not yet have been assigned. So assign one. if (!data.$region.is('[data-quickedit-entity-instance-id]')) { data.$region.once('quickedit'); processEntity(data.$region.get(0)); @@ -288,40 +127,21 @@ el: data.$el[0], region: data.$region[0] }; - // Set up contextual links for this, otherwise queue it to be set up - // later. + if (!initializeEntityContextualLink(contextualLink)) { contextualLinksQueue.push(contextualLink); } } }); - /** - * Extracts the entity ID from a field ID. - * - * @param {string} fieldID - * A field ID: a string of the format - * `<entity type>/<id>/<field name>/<language>/<view mode>`. - * - * @return {string} - * An entity ID: a string of the format `<entity type>/<id>`. - */ function extractEntityID(fieldID) { return fieldID.split('/').slice(0, 2).join('/'); } - /** - * Initialize the Quick Edit app. - * - * @param {HTMLElement} bodyElement - * This document's body element. - */ function initQuickEdit(bodyElement) { Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection(); Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection(); - // Instantiate AppModel (application state) and AppView, which is the - // controller of the whole in-place editing experience. Drupal.quickedit.app = new Drupal.quickedit.AppView({ el: bodyElement, model: new Drupal.quickedit.AppModel(), @@ -330,56 +150,32 @@ }); } - /** - * Assigns the entity an instance ID. - * - * @param {HTMLElement} entityElement - * A Drupal Entity API entity's DOM element with a data-quickedit-entity-id - * attribute. - */ function processEntity(entityElement) { var entityID = entityElement.getAttribute('data-quickedit-entity-id'); if (!entityInstancesTracker.hasOwnProperty(entityID)) { entityInstancesTracker[entityID] = 0; - } - else { + } else { entityInstancesTracker[entityID]++; } - // Set the calculated entity instance ID for this element. var entityInstanceID = entityInstancesTracker[entityID]; entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID); } - /** - * Fetch the field's metadata; queue or initialize it (if EntityModel exists). - * - * @param {HTMLElement} fieldElement - * A Drupal Field API field's DOM element with a data-quickedit-field-id - * attribute. - */ function processField(fieldElement) { var metadata = Drupal.quickedit.metadata; var fieldID = fieldElement.getAttribute('data-quickedit-field-id'); var entityID = extractEntityID(fieldID); - // Figure out the instance ID by looking at the ancestor - // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id - // attribute. + var entityElementSelector = '[data-quickedit-entity-id="' + entityID + '"]'; var entityElement = $(fieldElement).closest(entityElementSelector); - // In the case of a full entity view page, the entity title is rendered - // outside of "the entity DOM node": it's rendered as the page title. So in - // this case, we find the lowest common parent element (deepest in the tree) - // and consider that the entity element. + if (entityElement.length === 0) { var $lowestCommonParent = $(entityElementSelector).parents().has(fieldElement).first(); entityElement = $lowestCommonParent.find(entityElementSelector); } - var entityInstanceID = entityElement - .get(0) - .getAttribute('data-quickedit-entity-instance-id'); + var entityInstanceID = entityElement.get(0).getAttribute('data-quickedit-entity-instance-id'); - // Early-return if metadata for this field is missing. if (!metadata.has(fieldID)) { fieldsMetadataQueue.push({ el: fieldElement, @@ -389,35 +185,18 @@ }); return; } - // Early-return if the user is not allowed to in-place edit this field. + if (metadata.get(fieldID, 'access') !== true) { return; } - // If an EntityModel for this field already exists (and hence also a "Quick - // edit" contextual link), then initialize it immediately. - if (Drupal.quickedit.collections.entities.findWhere({entityID: entityID, entityInstanceID: entityInstanceID})) { + if (Drupal.quickedit.collections.entities.findWhere({ entityID: entityID, entityInstanceID: entityInstanceID })) { initializeField(fieldElement, fieldID, entityID, entityInstanceID); - } - // Otherwise: queue the field. It is now available to be set up when its - // corresponding entity becomes in-place editable. - else { - fieldsAvailableQueue.push({el: fieldElement, fieldID: fieldID, entityID: entityID, entityInstanceID: entityInstanceID}); - } + } else { + fieldsAvailableQueue.push({ el: fieldElement, fieldID: fieldID, entityID: entityID, entityInstanceID: entityInstanceID }); + } } - /** - * Initialize a field; create FieldModel. - * - * @param {HTMLElement} fieldElement - * The field's DOM element. - * @param {string} fieldID - * The field's ID. - * @param {string} entityID - * The field's entity's ID. - * @param {string} entityInstanceID - * The field's entity's instance ID. - */ function initializeField(fieldElement, fieldID, entityID, entityInstanceID) { var entity = Drupal.quickedit.collections.entities.findWhere({ entityID: entityID, @@ -426,7 +205,6 @@ $(fieldElement).addClass('quickedit-field'); - // The FieldModel stores the state of an in-place editable entity field. var field = new Drupal.quickedit.FieldModel({ el: fieldElement, fieldID: fieldID, @@ -436,25 +214,15 @@ acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app) }); - // Track all fields on the page. Drupal.quickedit.collections.fields.add(field); } - /** - * Fetches metadata for fields whose metadata is missing. - * - * Fields whose metadata is missing are tracked at fieldsMetadataQueue. - * - * @param {function} callback - * A callback function that receives field elements whose metadata will just - * have been fetched. - */ 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, Drupal.quickedit.metadata.intersection(entityIDs)); fieldsMetadataQueue = []; @@ -466,8 +234,7 @@ 'entities[]': entityIDs }, dataType: 'json', - success: function (results) { - // Store the metadata. + success: function success(results) { _.each(results, function (fieldMetadata, fieldID) { Drupal.quickedit.metadata.add(fieldID, fieldMetadata); }); @@ -478,16 +245,6 @@ } } - /** - * Loads missing in-place editor's attachments (JavaScript and CSS files). - * - * Missing in-place editors are those whose fields are actively being used on - * the page but don't have. - * - * @param {function} callback - * Callback function to be called when the missing in-place editors (if any) - * have been inserted into the DOM. i.e. they may still be loading. - */ function loadMissingEditors(callback) { var loadedEditors = _.keys(Drupal.quickedit.editors); var missingEditors = []; @@ -495,11 +252,7 @@ var metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID')); if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) { missingEditors.push(metadata.editor); - // Set a stub, to prevent subsequent calls to loadMissingEditors() from - // loading the same in-place editor again. Loading an in-place editor - // requires talking to a server, to download its JavaScript, then - // executing its JavaScript, and only then its Drupal.quickedit.editors - // entry will be set. + Drupal.quickedit.editors[metadata.editor] = false; } }); @@ -509,47 +262,23 @@ return; } - // @see https://www.drupal.org/node/2029999. - // Create a Drupal.Ajax instance to load the form. var loadEditorsAjax = Drupal.ajax({ url: Drupal.url('quickedit/attachments'), - submit: {'editors[]': missingEditors} + submit: { 'editors[]': missingEditors } }); - // 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; 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 any missing attachments. + loadEditorsAjax.execute(); } - /** - * Attempts to set up a "Quick edit" link and corresponding EntityModel. - * - * @param {object} contextualLink - * An object with the following properties: - * - String entityID: a Quick Edit entity identifier, e.g. "node/1" or - * "block_content/5". - * - String entityInstanceID: a Quick Edit entity instance identifier, - * e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st - * instance of this entity). - * - DOM el: element pointing to the contextual links placeholder for this - * entity. - * - DOM region: element pointing to the contextual region of this entity. - * - * @return {bool} - * Returns true when a contextual the given contextual link metadata can be - * removed from the queue (either because the contextual link has been set - * up or because it is certain that in-place editing is not allowed for any - * of its fields). Returns false otherwise. - */ function initializeEntityContextualLink(contextualLink) { var metadata = Drupal.quickedit.metadata; - // Check if the user has permission to edit at least one of them. + function hasFieldWithPermission(fieldIDs) { for (var i = 0; i < fieldIDs.length; i++) { var fieldID = fieldIDs[i]; @@ -560,106 +289,72 @@ return false; } - // Checks if the metadata for all given field IDs exists. function allMetadataExists(fieldIDs) { return fieldIDs.length === metadata.intersection(fieldIDs).length; } - // Find all fields for this entity instance and collect their field IDs. var fields = _.where(fieldsAvailableQueue, { entityID: contextualLink.entityID, entityInstanceID: contextualLink.entityInstanceID }); var fieldIDs = _.pluck(fields, 'fieldID'); - // No fields found yet. if (fieldIDs.length === 0) { return false; - } - // The entity for the given contextual link contains at least one field that - // the current user may edit in-place; instantiate EntityModel, - // EntityDecorationView and ContextualLinkView. - else if (hasFieldWithPermission(fieldIDs)) { - var entityModel = new Drupal.quickedit.EntityModel({ - el: contextualLink.region, - entityID: contextualLink.entityID, - entityInstanceID: contextualLink.entityInstanceID, - id: contextualLink.entityID + '[' + contextualLink.entityInstanceID + ']', - label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label') - }); - Drupal.quickedit.collections.entities.add(entityModel); - // Create an EntityDecorationView associated with the root DOM node of the - // entity. - var entityDecorationView = new Drupal.quickedit.EntityDecorationView({ - el: contextualLink.region, - model: entityModel - }); - entityModel.set('entityDecorationView', entityDecorationView); + } else if (hasFieldWithPermission(fieldIDs)) { + var entityModel = new Drupal.quickedit.EntityModel({ + el: contextualLink.region, + entityID: contextualLink.entityID, + entityInstanceID: contextualLink.entityInstanceID, + id: contextualLink.entityID + '[' + contextualLink.entityInstanceID + ']', + label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label') + }); + Drupal.quickedit.collections.entities.add(entityModel); - // Initialize all queued fields within this entity (creates FieldModels). - _.each(fields, function (field) { - initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID); - }); - fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields); - - // Initialization should only be called once. Use Underscore's once method - // to get a one-time use version of the function. - var initContextualLink = _.once(function () { - var $links = $(contextualLink.el).find('.contextual-links'); - var contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({ - el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links), - model: entityModel, - appModel: Drupal.quickedit.app.model - }, options)); - entityModel.set('contextualLinkView', contextualLinkView); - }); + var entityDecorationView = new Drupal.quickedit.EntityDecorationView({ + el: contextualLink.region, + model: entityModel + }); + entityModel.set('entityDecorationView', entityDecorationView); + + _.each(fields, function (field) { + initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID); + }); + fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields); + + var initContextualLink = _.once(function () { + var $links = $(contextualLink.el).find('.contextual-links'); + var contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({ + el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links), + model: entityModel, + appModel: Drupal.quickedit.app.model + }, options)); + entityModel.set('contextualLinkView', contextualLinkView); + }); - // Set up ContextualLinkView after loading any missing in-place editors. - loadMissingEditors(initContextualLink); + loadMissingEditors(initContextualLink); - return true; - } - // There was not at least one field that the current user may edit in-place, - // even though the metadata for all fields within this entity is available. - else if (allMetadataExists(fieldIDs)) { - return true; - } + return true; + } else if (allMetadataExists(fieldIDs)) { + return true; + } return false; } - /** - * Delete models and queue items that are contained within a given context. - * - * Deletes any contained EntityModels (plus their associated FieldModels and - * ContextualLinkView) and FieldModels, as well as the corresponding queues. - * - * After EntityModels, FieldModels must also be deleted, because it is - * possible in Drupal for a field DOM element to exist outside of the entity - * DOM element, e.g. when viewing the full node, the title of the node is not - * rendered within the node (the entity) but as the page title. - * - * Note: this will not delete an entity that is actively being in-place - * edited. - * - * @param {jQuery} $context - * The context within which to delete. - */ function deleteContainedModelsAndQueues($context) { $context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each(function (index, entityElement) { - // Delete entity model. - var entityModel = Drupal.quickedit.collections.entities.findWhere({el: entityElement}); + var entityModel = Drupal.quickedit.collections.entities.findWhere({ el: entityElement }); if (entityModel) { var contextualLinkView = entityModel.get('contextualLinkView'); contextualLinkView.undelegateEvents(); contextualLinkView.remove(); - // Remove the EntityDecorationView. + entityModel.get('entityDecorationView').remove(); - // Destroy the EntityModel; this will also destroy its FieldModels. + entityModel.destroy(); } - // Filter queue. function hasOtherRegion(contextualLink) { return contextualLink.region !== entityElement; } @@ -668,12 +363,10 @@ }); $context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each(function (index, fieldElement) { - // Delete field models. - Drupal.quickedit.collections.fields.chain() - .filter(function (fieldModel) { return fieldModel.get('el') === fieldElement; }) - .invoke('destroy'); + Drupal.quickedit.collections.fields.chain().filter(function (fieldModel) { + return fieldModel.get('el') === fieldElement; + }).invoke('destroy'); - // Filter queues. function hasOtherFieldElement(field) { return field.el !== fieldElement; } @@ -682,5 +375,4 @@ fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement); }); } - -})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage); +})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage); \ No newline at end of file diff --git a/core/modules/quickedit/js/theme.es6.js b/core/modules/quickedit/js/theme.es6.js new file mode 100644 index 000000000000..93dc3f238df2 --- /dev/null +++ b/core/modules/quickedit/js/theme.es6.js @@ -0,0 +1,187 @@ +/** + * @file + * Provides theme functions for all of Quick Edit's client-side HTML. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Theme function for a "backstage" for the Quick Edit module. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {string} settings.id + * The id to apply to the backstage. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditBackstage = function (settings) { + var html = ''; + html += '<div id="' + settings.id + '" />'; + return html; + }; + + /** + * Theme function for a toolbar container of the Quick Edit module. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {string} settings.id + * the id to apply to the backstage. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditEntityToolbar = function (settings) { + var html = ''; + html += '<div id="' + settings.id + '" class="quickedit quickedit-toolbar-container clearfix">'; + html += '<i class="quickedit-toolbar-pointer"></i>'; + html += '<div class="quickedit-toolbar-content">'; + html += '<div class="quickedit-toolbar quickedit-toolbar-entity clearfix icon icon-pencil">'; + html += '<div class="quickedit-toolbar-label" />'; + html += '</div>'; + html += '<div class="quickedit-toolbar quickedit-toolbar-field clearfix" />'; + html += '</div><div class="quickedit-toolbar-lining"></div></div>'; + return html; + }; + + /** + * Theme function for a toolbar container of the Quick Edit module. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {string} settings.entityLabel + * The title of the active entity. + * @param {string} settings.fieldLabel + * The label of the highlighted or active field. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditEntityToolbarLabel = function (settings) { + // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437 + return '<span class="field">' + Drupal.checkPlain(settings.fieldLabel) + '</span>' + Drupal.checkPlain(settings.entityLabel); + }; + + /** + * Element defining a containing box for the placement of the entity toolbar. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditEntityToolbarFence = function () { + return '<div id="quickedit-toolbar-fence" />'; + }; + + /** + * Theme function for a toolbar container of the Quick Edit module. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {string} settings.id + * The id to apply to the toolbar container. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditFieldToolbar = function (settings) { + return '<div id="' + settings.id + '" />'; + }; + + /** + * Theme function for a toolbar toolgroup of the Quick Edit module. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {string} [settings.id] + * The id of the toolgroup. + * @param {string} settings.classes + * The class of the toolgroup. + * @param {Array} settings.buttons + * See {@link Drupal.theme.quickeditButtons}. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditToolgroup = function (settings) { + // Classes. + var classes = (settings.classes || []); + classes.unshift('quickedit-toolgroup'); + var html = ''; + html += '<div class="' + classes.join(' ') + '"'; + if (settings.id) { + html += ' id="' + settings.id + '"'; + } + html += '>'; + html += Drupal.theme('quickeditButtons', {buttons: settings.buttons}); + html += '</div>'; + return html; + }; + + /** + * Theme function for buttons of the Quick Edit module. + * + * Can be used for the buttons both in the toolbar toolgroups and in the + * modal. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {Array} settings.buttons + * - String type: the type of the button (defaults to 'button') + * - Array classes: the classes of the button. + * - String label: the label of the button. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditButtons = function (settings) { + var html = ''; + for (var i = 0; i < settings.buttons.length; i++) { + var button = settings.buttons[i]; + if (!button.hasOwnProperty('type')) { + button.type = 'button'; + } + // Attributes. + var attributes = []; + var attrMap = settings.buttons[i].attributes || {}; + for (var attr in attrMap) { + if (attrMap.hasOwnProperty(attr)) { + attributes.push(attr + ((attrMap[attr]) ? '="' + attrMap[attr] + '"' : '')); + } + } + html += '<button type="' + button.type + '" class="' + button.classes + '"' + ' ' + attributes.join(' ') + '>'; + html += button.label; + html += '</button>'; + } + return html; + }; + + /** + * Theme function for a form container of the Quick Edit module. + * + * @param {object} settings + * Settings object used to construct the markup. + * @param {string} settings.id + * The id to apply to the toolbar container. + * @param {string} settings.loadingMsg + * The message to show while loading. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditFormContainer = function (settings) { + var html = ''; + html += '<div id="' + settings.id + '" class="quickedit-form-container">'; + html += ' <div class="quickedit-form">'; + html += ' <div class="placeholder">'; + html += settings.loadingMsg; + html += ' </div>'; + html += ' </div>'; + html += '</div>'; + return html; + }; + +})(jQuery, Drupal); diff --git a/core/modules/quickedit/js/theme.js b/core/modules/quickedit/js/theme.js index 93dc3f238df2..14c3cc3a6c2f 100644 --- a/core/modules/quickedit/js/theme.js +++ b/core/modules/quickedit/js/theme.js @@ -1,40 +1,21 @@ /** - * @file - * Provides theme functions for all of Quick Edit's client-side HTML. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/theme.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Theme function for a "backstage" for the Quick Edit module. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {string} settings.id - * The id to apply to the backstage. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditBackstage = function (settings) { var html = ''; html += '<div id="' + settings.id + '" />'; return html; }; - /** - * Theme function for a toolbar container of the Quick Edit module. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {string} settings.id - * the id to apply to the backstage. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditEntityToolbar = function (settings) { var html = ''; html += '<div id="' + settings.id + '" class="quickedit quickedit-toolbar-container clearfix">'; @@ -48,67 +29,20 @@ return html; }; - /** - * Theme function for a toolbar container of the Quick Edit module. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {string} settings.entityLabel - * The title of the active entity. - * @param {string} settings.fieldLabel - * The label of the highlighted or active field. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditEntityToolbarLabel = function (settings) { - // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437 return '<span class="field">' + Drupal.checkPlain(settings.fieldLabel) + '</span>' + Drupal.checkPlain(settings.entityLabel); }; - /** - * Element defining a containing box for the placement of the entity toolbar. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditEntityToolbarFence = function () { return '<div id="quickedit-toolbar-fence" />'; }; - /** - * Theme function for a toolbar container of the Quick Edit module. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {string} settings.id - * The id to apply to the toolbar container. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditFieldToolbar = function (settings) { return '<div id="' + settings.id + '" />'; }; - /** - * Theme function for a toolbar toolgroup of the Quick Edit module. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {string} [settings.id] - * The id of the toolgroup. - * @param {string} settings.classes - * The class of the toolgroup. - * @param {Array} settings.buttons - * See {@link Drupal.theme.quickeditButtons}. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditToolgroup = function (settings) { - // Classes. - var classes = (settings.classes || []); + var classes = settings.classes || []; classes.unshift('quickedit-toolgroup'); var html = ''; html += '<div class="' + classes.join(' ') + '"'; @@ -116,27 +50,11 @@ html += ' id="' + settings.id + '"'; } html += '>'; - html += Drupal.theme('quickeditButtons', {buttons: settings.buttons}); + html += Drupal.theme('quickeditButtons', { buttons: settings.buttons }); html += '</div>'; return html; }; - /** - * Theme function for buttons of the Quick Edit module. - * - * Can be used for the buttons both in the toolbar toolgroups and in the - * modal. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {Array} settings.buttons - * - String type: the type of the button (defaults to 'button') - * - Array classes: the classes of the button. - * - String label: the label of the button. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditButtons = function (settings) { var html = ''; for (var i = 0; i < settings.buttons.length; i++) { @@ -144,12 +62,12 @@ if (!button.hasOwnProperty('type')) { button.type = 'button'; } - // Attributes. + var attributes = []; var attrMap = settings.buttons[i].attributes || {}; for (var attr in attrMap) { if (attrMap.hasOwnProperty(attr)) { - attributes.push(attr + ((attrMap[attr]) ? '="' + attrMap[attr] + '"' : '')); + attributes.push(attr + (attrMap[attr] ? '="' + attrMap[attr] + '"' : '')); } } html += '<button type="' + button.type + '" class="' + button.classes + '"' + ' ' + attributes.join(' ') + '>'; @@ -159,19 +77,6 @@ return html; }; - /** - * Theme function for a form container of the Quick Edit module. - * - * @param {object} settings - * Settings object used to construct the markup. - * @param {string} settings.id - * The id to apply to the toolbar container. - * @param {string} settings.loadingMsg - * The message to show while loading. - * - * @return {string} - * The corresponding HTML. - */ Drupal.theme.quickeditFormContainer = function (settings) { var html = ''; html += '<div id="' + settings.id + '" class="quickedit-form-container">'; @@ -183,5 +88,4 @@ html += '</div>'; return html; }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/util.es6.js b/core/modules/quickedit/js/util.es6.js new file mode 100644 index 000000000000..4b0a4c43e6e8 --- /dev/null +++ b/core/modules/quickedit/js/util.es6.js @@ -0,0 +1,213 @@ +/** + * @file + * Provides utility functions for Quick Edit. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * @namespace + */ + Drupal.quickedit.util = Drupal.quickedit.util || {}; + + /** + * @namespace + */ + Drupal.quickedit.util.constants = {}; + + /** + * + * @type {string} + */ + Drupal.quickedit.util.constants.transitionEnd = 'transitionEnd.quickedit webkitTransitionEnd.quickedit transitionend.quickedit msTransitionEnd.quickedit oTransitionEnd.quickedit'; + + /** + * Converts a field id into a formatted url path. + * + * @example + * Drupal.quickedit.util.buildUrl( + * 'node/1/body/und/full', + * '/quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode' + * ); + * + * @param {string} id + * The id of an editable field. + * @param {string} urlFormat + * The Controller route for field processing. + * + * @return {string} + * The formatted URL. + */ + Drupal.quickedit.util.buildUrl = function (id, urlFormat) { + var parts = id.split('/'); + return Drupal.formatString(decodeURIComponent(urlFormat), { + '!entity_type': parts[0], + '!id': parts[1], + '!field_name': parts[2], + '!langcode': parts[3], + '!view_mode': parts[4] + }); + }; + + /** + * Shows a network error modal dialog. + * + * @param {string} title + * The title to use in the modal dialog. + * @param {string} message + * The message to use in the modal dialog. + */ + Drupal.quickedit.util.networkErrorModal = function (title, message) { + var $message = $('<div>' + message + '</div>'); + var networkErrorModal = Drupal.dialog($message.get(0), { + title: title, + dialogClass: 'quickedit-network-error', + buttons: [ + { + text: Drupal.t('OK'), + click: function () { + networkErrorModal.close(); + }, + primary: true + } + ], + create: function () { + $(this).parent().find('.ui-dialog-titlebar-close').remove(); + }, + close: function (event) { + // Automatically destroy the DOM element that was used for the dialog. + $(event.target).remove(); + } + }); + networkErrorModal.showModal(); + }; + + /** + * @namespace + */ + Drupal.quickedit.util.form = { + + /** + * Loads a form, calls a callback to insert. + * + * Leverages {@link Drupal.Ajax}' ability to have scoped (per-instance) + * command implementations to be able to call a callback. + * + * @param {object} options + * An object with the following keys: + * @param {string} options.fieldID + * The field ID that uniquely identifies the field for which this form + * will be loaded. + * @param {bool} options.nocssjs + * Boolean indicating whether no CSS and JS should be returned (necessary + * when the form is invisible to the user). + * @param {bool} options.reset + * Boolean indicating whether the data stored for this field's entity in + * PrivateTempStore 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 commands. + */ + load: function (options, callback) { + var fieldID = options.fieldID; + + // Create a Drupal.ajax instance to load the form. + var formLoaderAjax = Drupal.ajax({ + url: Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode')), + submit: { + nocssjs: options.nocssjs, + reset: options.reset + }, + error: function (xhr, url) { + // Show a modal to inform the user of the network error. + var fieldLabel = Drupal.quickedit.metadata.get(fieldID, 'label'); + var message = Drupal.t('Could not load the form for <q>@field-label</q>, either due to a website problem or a network connection problem.<br>Please try again.', {'@field-label': fieldLabel}); + Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message); + + // Change the state back to "candidate", to allow the user to start + // in-place editing of the field again. + var fieldModel = Drupal.quickedit.app.model.get('activeField'); + fieldModel.set('state', 'candidate'); + } + }); + // Implement a scoped quickeditFieldForm AJAX command: calls the callback. + formLoaderAjax.commands.quickeditFieldForm = function (ajax, response, status) { + callback(response.data, ajax); + Drupal.ajax.instances[this.instanceIndex] = null; + }; + // This will ensure our scoped quickeditFieldForm AJAX command gets + // called. + formLoaderAjax.execute(); + }, + + /** + * Creates a {@link Drupal.Ajax} instance that is used to save a form. + * + * @param {object} options + * Submit options to the form. + * @param {bool} options.nocssjs + * Boolean indicating whether no CSS and JS should be returned (necessary + * when the form is invisible to the user). + * @param {Array.<string>} options.other_view_modes + * Array containing view mode IDs (of other instances of this field on the + * page). + * @param {jQuery} $submit + * The submit element. + * + * @return {Drupal.Ajax} + * A {@link Drupal.Ajax} instance. + */ + ajaxifySaving: function (options, $submit) { + // Re-wire the form to handle submit. + var settings = { + url: $submit.closest('form').attr('action'), + setClick: true, + event: 'click.quickedit', + progress: false, + submit: { + nocssjs: options.nocssjs, + other_view_modes: options.other_view_modes + }, + + /** + * Reimplement the success handler. + * + * Ensure {@link Drupal.attachBehaviors} does not get called on the + * form. + * + * @param {Drupal.AjaxCommands~commandDefinition} response + * The Drupal AJAX response. + * @param {number} [status] + * The HTTP status code. + */ + 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); + } + } + }, + base: $submit.attr('id'), + element: $submit[0] + }; + + return Drupal.ajax(settings); + }, + + /** + * Cleans up the {@link Drupal.Ajax} instance that is used to save the form. + * + * @param {Drupal.Ajax} ajax + * A {@link Drupal.Ajax} instance that was returned by + * {@link Drupal.quickedit.form.ajaxifySaving}. + */ + unajaxifySaving: function (ajax) { + $(ajax.element).off('click.quickedit'); + } + + }; + +})(jQuery, Drupal); diff --git a/core/modules/quickedit/js/util.js b/core/modules/quickedit/js/util.js index 4b0a4c43e6e8..677d5beb8930 100644 --- a/core/modules/quickedit/js/util.js +++ b/core/modules/quickedit/js/util.js @@ -1,45 +1,21 @@ /** - * @file - * Provides utility functions for Quick Edit. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/util.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * @namespace - */ Drupal.quickedit.util = Drupal.quickedit.util || {}; - /** - * @namespace - */ Drupal.quickedit.util.constants = {}; - /** - * - * @type {string} - */ Drupal.quickedit.util.constants.transitionEnd = 'transitionEnd.quickedit webkitTransitionEnd.quickedit transitionend.quickedit msTransitionEnd.quickedit oTransitionEnd.quickedit'; - /** - * Converts a field id into a formatted url path. - * - * @example - * Drupal.quickedit.util.buildUrl( - * 'node/1/body/und/full', - * '/quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode' - * ); - * - * @param {string} id - * The id of an editable field. - * @param {string} urlFormat - * The Controller route for field processing. - * - * @return {string} - * The formatted URL. - */ Drupal.quickedit.util.buildUrl = function (id, urlFormat) { var parts = id.split('/'); return Drupal.formatString(decodeURIComponent(urlFormat), { @@ -51,117 +27,57 @@ }); }; - /** - * Shows a network error modal dialog. - * - * @param {string} title - * The title to use in the modal dialog. - * @param {string} message - * The message to use in the modal dialog. - */ Drupal.quickedit.util.networkErrorModal = function (title, message) { var $message = $('<div>' + message + '</div>'); var networkErrorModal = Drupal.dialog($message.get(0), { title: title, dialogClass: 'quickedit-network-error', - buttons: [ - { - text: Drupal.t('OK'), - click: function () { - networkErrorModal.close(); - }, - primary: true - } - ], - create: function () { + buttons: [{ + text: Drupal.t('OK'), + click: function click() { + networkErrorModal.close(); + }, + primary: true + }], + create: function create() { $(this).parent().find('.ui-dialog-titlebar-close').remove(); }, - close: function (event) { - // Automatically destroy the DOM element that was used for the dialog. + close: function close(event) { $(event.target).remove(); } }); networkErrorModal.showModal(); }; - /** - * @namespace - */ Drupal.quickedit.util.form = { - - /** - * Loads a form, calls a callback to insert. - * - * Leverages {@link Drupal.Ajax}' ability to have scoped (per-instance) - * command implementations to be able to call a callback. - * - * @param {object} options - * An object with the following keys: - * @param {string} options.fieldID - * The field ID that uniquely identifies the field for which this form - * will be loaded. - * @param {bool} options.nocssjs - * Boolean indicating whether no CSS and JS should be returned (necessary - * when the form is invisible to the user). - * @param {bool} options.reset - * Boolean indicating whether the data stored for this field's entity in - * PrivateTempStore 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 commands. - */ - load: function (options, callback) { + load: function load(options, callback) { var fieldID = options.fieldID; - // Create a Drupal.ajax instance to load the form. var formLoaderAjax = Drupal.ajax({ url: Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode')), submit: { nocssjs: options.nocssjs, reset: options.reset }, - error: function (xhr, url) { - // Show a modal to inform the user of the network error. + error: function error(xhr, url) { var fieldLabel = Drupal.quickedit.metadata.get(fieldID, 'label'); - var message = Drupal.t('Could not load the form for <q>@field-label</q>, either due to a website problem or a network connection problem.<br>Please try again.', {'@field-label': fieldLabel}); + var message = Drupal.t('Could not load the form for <q>@field-label</q>, either due to a website problem or a network connection problem.<br>Please try again.', { '@field-label': fieldLabel }); Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message); - // Change the state back to "candidate", to allow the user to start - // in-place editing of the field again. var fieldModel = Drupal.quickedit.app.model.get('activeField'); fieldModel.set('state', 'candidate'); } }); - // Implement a scoped quickeditFieldForm AJAX command: calls the callback. + formLoaderAjax.commands.quickeditFieldForm = function (ajax, response, status) { callback(response.data, ajax); Drupal.ajax.instances[this.instanceIndex] = null; }; - // This will ensure our scoped quickeditFieldForm AJAX command gets - // called. + formLoaderAjax.execute(); }, - /** - * Creates a {@link Drupal.Ajax} instance that is used to save a form. - * - * @param {object} options - * Submit options to the form. - * @param {bool} options.nocssjs - * Boolean indicating whether no CSS and JS should be returned (necessary - * when the form is invisible to the user). - * @param {Array.<string>} options.other_view_modes - * Array containing view mode IDs (of other instances of this field on the - * page). - * @param {jQuery} $submit - * The submit element. - * - * @return {Drupal.Ajax} - * A {@link Drupal.Ajax} instance. - */ - ajaxifySaving: function (options, $submit) { - // Re-wire the form to handle submit. + ajaxifySaving: function ajaxifySaving(options, $submit) { var settings = { url: $submit.closest('form').attr('action'), setClick: true, @@ -172,18 +88,7 @@ other_view_modes: options.other_view_modes }, - /** - * Reimplement the success handler. - * - * Ensure {@link Drupal.attachBehaviors} does not get called on the - * form. - * - * @param {Drupal.AjaxCommands~commandDefinition} response - * The Drupal AJAX response. - * @param {number} [status] - * The HTTP status code. - */ - success: function (response, status) { + success: function success(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); @@ -197,17 +102,9 @@ return Drupal.ajax(settings); }, - /** - * Cleans up the {@link Drupal.Ajax} instance that is used to save the form. - * - * @param {Drupal.Ajax} ajax - * A {@link Drupal.Ajax} instance that was returned by - * {@link Drupal.quickedit.form.ajaxifySaving}. - */ - unajaxifySaving: function (ajax) { + unajaxifySaving: function unajaxifySaving(ajax) { $(ajax.element).off('click.quickedit'); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/views/AppView.es6.js b/core/modules/quickedit/js/views/AppView.es6.js new file mode 100644 index 000000000000..b09a110b8bf7 --- /dev/null +++ b/core/modules/quickedit/js/views/AppView.es6.js @@ -0,0 +1,600 @@ +/** + * @file + * A Backbone View that controls the overall "in-place editing application". + * + * @see Drupal.quickedit.AppModel + */ + +(function ($, _, Backbone, Drupal) { + + 'use strict'; + + // Indicates whether the page should be reloaded after in-place editing has + // shut down. A page reload is necessary to re-instate the original HTML of + // the edited fields if in-place editing has been canceled and one or more of + // the entity's fields were saved to PrivateTempStore: one of them may have + // been changed to the empty value and hence may have been rerendered as the + // empty string, which makes it impossible for Quick Edit to know where to + // restore the original HTML. + var reload = false; + + Drupal.quickedit.AppView = Backbone.View.extend(/** @lends Drupal.quickedit.AppView# */{ + + /** + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * An object with the following keys: + * @param {Drupal.quickedit.AppModel} options.model + * The application state model. + * @param {Drupal.quickedit.EntityCollection} options.entitiesCollection + * All on-page entities. + * @param {Drupal.quickedit.FieldCollection} options.fieldsCollection + * All on-page fields + */ + initialize: function (options) { + // AppView's configuration for handling states. + // @see Drupal.quickedit.FieldModel.states + this.activeFieldStates = ['activating', 'active']; + this.singleFieldStates = ['highlighted', 'activating', 'active']; + this.changedFieldStates = ['changed', 'saving', 'saved', 'invalid']; + this.readyFieldStates = ['candidate', 'highlighted']; + + // Track app state. + this.listenTo(options.entitiesCollection, 'change:state', this.appStateChange); + this.listenTo(options.entitiesCollection, 'change:isActive', this.enforceSingleActiveEntity); + + // Track app state. + this.listenTo(options.fieldsCollection, 'change:state', this.editorStateChange); + // Respond to field model HTML representation change events. + this.listenTo(options.fieldsCollection, 'change:html', this.renderUpdatedField); + this.listenTo(options.fieldsCollection, 'change:html', this.propagateUpdatedField); + // Respond to addition. + this.listenTo(options.fieldsCollection, 'add', this.rerenderedFieldToCandidate); + // Respond to destruction. + this.listenTo(options.fieldsCollection, 'destroy', this.teardownEditor); + }, + + /** + * Handles setup/teardown and state changes when the active entity changes. + * + * @param {Drupal.quickedit.EntityModel} entityModel + * An instance of the EntityModel class. + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.EntityModel.states}. + */ + appStateChange: function (entityModel, state) { + var app = this; + var entityToolbarView; + switch (state) { + case 'launching': + reload = false; + // First, create an entity toolbar view. + entityToolbarView = new Drupal.quickedit.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 in-place editors. + entityModel.get('fields').each(function (fieldModel) { + app.teardownEditor(fieldModel); + }); + // Second, tear down the entity toolbar view. + if (entityToolbarView) { + entityToolbarView.remove(); + delete entityModel.toolbarView; + } + // A page reload may be necessary to re-instate the original HTML of + // the edited fields. + if (reload) { + reload = false; + location.reload(); + } + break; + } + }, + + /** + * Accepts or reject editor (Editor) state changes. + * + * This is what ensures that the app is in control of what happens. + * + * @param {string} from + * The previous state. + * @param {string} to + * The new state. + * @param {null|object} context + * The context that is trying to trigger the state change. + * @param {Drupal.quickedit.FieldModel} fieldModel + * The fieldModel to which this change applies. + * + * @return {bool} + * Whether the editor change was accepted or rejected. + */ + acceptEditorStateChange: function (from, to, context, fieldModel) { + var accept = true; + + // If the app is in view mode, then reject all state changes except for + // those to 'inactive'. + if (context && (context.reason === 'stop' || context.reason === 'rerender')) { + if (from === 'candidate' && to === 'inactive') { + accept = true; + } + } + // Handling of edit mode state changes is more granular. + else { + // In general, enforce the states sequence. Disallow going back from a + // "later" state to an "earlier" state, except in explicitly allowed + // cases. + if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) { + accept = false; + // Allow: activating/active -> candidate. + // Necessary to stop editing a field. + if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') { + accept = true; + } + // Allow: changed/invalid -> candidate. + // 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 field. + else if (from === 'highlighted' && to === 'candidate') { + accept = true; + } + // Allow: saved -> candidate. + // 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 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) { + var activeField; + var activeFieldState; + // Ensure only one field (editor) 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.readyFieldStates.indexOf(from) !== -1 || from === 'invalid') && this.activeFieldStates.indexOf(to) !== -1) { + activeField = this.model.get('activeField'); + if (activeField && activeField !== fieldModel) { + activeFieldState = activeField.get('state'); + // Allow the state change. If the state of the active field is: + // - 'activating' or 'active': change it to 'candidate' + // - 'changed' or 'invalid': change it to 'saving' + // - 'saving' or 'saved': don't do anything. + if (this.activeFieldStates.indexOf(activeFieldState) !== -1) { + activeField.set('state', 'candidate'); + } + else if (activeFieldState === 'changed' || activeFieldState === 'invalid') { + activeField.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('activeField', fieldModel); + accept = false; + } + // 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 + // mouseleave. + else if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + } + // When attempting to stop editing a changed/invalid property, ask for + // confirmation. + else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + else { + // Check whether the transition has been confirmed? + if (context && context.confirmed) { + accept = true; + } + } + } + } + } + + return accept; + }, + + /** + * Sets up the in-place editor for the given field. + * + * Must happen before the fieldModel's state is changed to 'candidate'. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * 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.quickedit.EditorModel(); + var editorView = new Drupal.quickedit.editors[editorName]({ + el: $(fieldModel.get('el')), + model: editorModel, + fieldModel: fieldModel + }); + + // Create in-place editor's toolbar for this field — stored inside the + // entity toolbar, the entity toolbar will position itself appropriately + // above (or below) the edited element. + var toolbarView = new Drupal.quickedit.FieldToolbarView({ + el: fieldToolbarRoot, + model: fieldModel, + $editedElement: $(editorView.getEditedElement()), + editorView: editorView, + entityModel: entityModel + }); + + // Create decoration for edited element: padding if necessary, sets + // classes on the element to style it according to the current state. + var decorationView = new Drupal.quickedit.FieldDecorationView({ + el: $(editorView.getEditedElement()), + model: fieldModel, + editorView: editorView + }); + + // Track these three views in FieldModel so that we can tear them down + // correctly. + fieldModel.editorView = editorView; + fieldModel.toolbarView = toolbarView; + fieldModel.decorationView = decorationView; + }, + + /** + * Tears down the in-place editor for the given field. + * + * Must happen after the fieldModel's state is changed to 'inactive'. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The field for which an in-place editor must be torn down. + */ + teardownEditor: function (fieldModel) { + // Early-return if this field was not yet decorated. + if (typeof fieldModel.editorView === 'undefined') { + return; + } + + // Unbind event handlers; remove toolbar element; delete toolbar view. + fieldModel.toolbarView.remove(); + delete fieldModel.toolbarView; + + // Unbind event handlers; delete decoration view. Don't remove the element + // because that would remove the field itself. + fieldModel.decorationView.remove(); + delete fieldModel.decorationView; + + // Unbind event handlers; delete editor view. Don't remove the element + // because that would remove the field itself. + fieldModel.editorView.remove(); + delete fieldModel.editorView; + }, + + /** + * Asks the user to confirm whether he wants to stop editing via a modal. + * + * @param {Drupal.quickedit.EntityModel} entityModel + * An instance of the EntityModel class. + * + * @see Drupal.quickedit.AppView#acceptEditorStateChange + */ + confirmEntityDeactivation: function (entityModel) { + var that = this; + var discardDialog; + + function closeDiscardDialog(action) { + discardDialog.close(action); + // The active modal has been removed. + that.model.set('activeModal', null); + + // If the targetState is saving, the field must be saved, then the + // entity must be saved. + if (action === 'save') { + entityModel.set('state', 'committing', {confirmed: true}); + } + else { + entityModel.set('state', 'deactivating', {confirmed: true}); + // Editing has been canceled and the changes will not be saved. Mark + // the page for reload if the entityModel declares that it requires + // a reload. + if (entityModel.get('reload')) { + reload = true; + entityModel.set('reload', false); + } + } + } + + // Only instantiate if there isn't a modal instance visible yet. + if (!this.model.get('activeModal')) { + var $unsavedChanges = $('<div>' + Drupal.t('You have unsaved changes') + '</div>'); + discardDialog = Drupal.dialog($unsavedChanges.get(0), { + title: Drupal.t('Discard changes?'), + dialogClass: 'quickedit-discard-modal', + resizable: false, + buttons: [ + { + text: Drupal.t('Save'), + click: function () { + closeDiscardDialog('save'); + }, + primary: true + }, + { + text: Drupal.t('Discard changes'), + click: function () { + closeDiscardDialog('discard'); + } + } + ], + // Prevent this modal from being closed without the user making a + // choice as per http://stackoverflow.com/a/5438771. + closeOnEscape: false, + create: function () { + $(this).parent().find('.ui-dialog-titlebar-close').remove(); + }, + beforeClose: false, + close: function (event) { + // Automatically destroy the DOM element that was used for the + // dialog. + $(event.target).remove(); + } + }); + this.model.set('activeModal', discardDialog); + + discardDialog.showModal(); + } + }, + + /** + * Reacts to field state changes; tracks global state. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The `fieldModel` holding the state. + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.FieldModel.states}. + */ + editorStateChange: function (fieldModel, state) { + var from = fieldModel.previous('state'); + var to = state; + + // Keep track of the highlighted field in the global state. + if (_.indexOf(this.singleFieldStates, to) !== -1 && this.model.get('highlightedField') !== fieldModel) { + this.model.set('highlightedField', fieldModel); + } + else if (this.model.get('highlightedField') === fieldModel && to === 'candidate') { + this.model.set('highlightedField', null); + } + + // Keep track of the active field in the global state. + if (_.indexOf(this.activeFieldStates, to) !== -1 && this.model.get('activeField') !== fieldModel) { + this.model.set('activeField', fieldModel); + } + else if (this.model.get('activeField') === fieldModel && to === 'candidate') { + // Discarded if it transitions from a changed state to 'candidate'. + if (from === 'changed' || from === 'invalid') { + fieldModel.editorView.revert(); + } + this.model.set('activeField', null); + } + }, + + /** + * Render an updated field (a field whose 'html' attribute changed). + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The FieldModel whose 'html' attribute changed. + * @param {string} html + * The updated 'html' attribute. + * @param {object} options + * An object with the following keys: + * @param {bool} options.propagation + * Whether this change to the 'html' attribute occurred because of the + * propagation of changes to another instance of this field. + */ + renderUpdatedField: function (fieldModel, html, options) { + // Get data necessary to rerender property before it is unavailable. + var $fieldWrapper = $(fieldModel.get('el')); + var $context = $fieldWrapper.parent(); + + var renderField = function () { + // Destroy the field model; this will cause all attached views to be + // destroyed too, and removal from all collections in which it exists. + fieldModel.destroy(); + + // Replace the old content with the new content. + $fieldWrapper.replaceWith(html); + + // Attach behaviors again to the modified piece of HTML; this will + // create a new field model and call rerenderedFieldToCandidate() with + // it. + Drupal.attachBehaviors($context.get(0)); + }; + + // When propagating the changes of another instance of this field, this + // field is not being actively edited and hence no state changes are + // necessary. So: only update the state of this field when the rerendering + // of this field happens not because of propagation, but because it is + // being edited itself. + if (!options.propagation) { + // Deferred because renderUpdatedField is reacting to a field model + // change event, and we want to make sure that event fully propagates + // before making another change to the same model. + _.defer(function () { + // First set the state to 'candidate', to allow all attached views to + // clean up all their "active state"-related changes. + fieldModel.set('state', 'candidate'); + + // Similarly, the above .set() call's change event must fully + // propagate before calling it again. + _.defer(function () { + // Set the field's state to 'inactive', to enable the updating of + // its DOM value. + fieldModel.set('state', 'inactive', {reason: 'rerender'}); + + renderField(); + }); + }); + } + else { + renderField(); + } + }, + + /** + * Propagates changes to an updated field to all instances of that field. + * + * @param {Drupal.quickedit.FieldModel} updatedField + * The FieldModel whose 'html' attribute changed. + * @param {string} html + * The updated 'html' attribute. + * @param {object} options + * An object with the following keys: + * @param {bool} options.propagation + * Whether this change to the 'html' attribute occurred because of the + * propagation of changes to another instance of this field. + * + * @see Drupal.quickedit.AppView#renderUpdatedField + */ + propagateUpdatedField: function (updatedField, html, options) { + // Don't propagate field updates that themselves were caused by + // propagation. + if (options.propagation) { + return; + } + + var htmlForOtherViewModes = updatedField.get('htmlForOtherViewModes'); + Drupal.quickedit.collections.fields + // Find all instances of fields that display the same logical field + // (same entity, same field, just a different instance and maybe a + // different view mode). + .where({logicalFieldID: updatedField.get('logicalFieldID')}) + .forEach(function (field) { + // Ignore the field that was already updated. + if (field === updatedField) { + return; + } + // If this other instance of the field has the same view mode, we can + // update it easily. + else if (field.getViewMode() === updatedField.getViewMode()) { + field.set('html', updatedField.get('html')); + } + // If this other instance of the field has a different view mode, and + // that is one of the view modes for which a re-rendered version is + // available (and that should be the case unless this field was only + // added to the page after editing of the updated field began), then + // use that view mode's re-rendered version. + else { + if (field.getViewMode() in htmlForOtherViewModes) { + field.set('html', htmlForOtherViewModes[field.getViewMode()], {propagation: true}); + } + } + }); + }, + + /** + * If the new in-place editable field is for the entity that's currently + * being edited, then transition it to the 'candidate' state. + * + * This happens when a field was modified, saved and hence rerendered. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * A field that was just added to the collection of fields. + */ + rerenderedFieldToCandidate: function (fieldModel) { + var activeEntity = Drupal.quickedit.collections.entities.findWhere({isActive: true}); + + // Early-return if there is no active entity. + if (!activeEntity) { + return; + } + + // If the field's entity is the active entity, make it a candidate. + if (fieldModel.get('entity') === activeEntity) { + this.setupEditor(fieldModel); + fieldModel.set('state', 'candidate'); + } + }, + + /** + * EntityModel Collection change handler. + * + * Handler is called `change:isActive` and enforces a single active entity. + * + * @param {Drupal.quickedit.EntityModel} changedEntityModel + * The entityModel instance whose active state has changed. + */ + enforceSingleActiveEntity: function (changedEntityModel) { + // When an entity is deactivated, we don't need to enforce anything. + if (changedEntityModel.get('isActive') === false) { + return; + } + + // This entity was activated; deactivate all other entities. + changedEntityModel.collection.chain() + .filter(function (entityModel) { + return entityModel.get('isActive') === true && entityModel !== changedEntityModel; + }) + .each(function (entityModel) { + entityModel.set('state', 'deactivating'); + }); + } + + }); + +}(jQuery, _, Backbone, Drupal)); diff --git a/core/modules/quickedit/js/views/AppView.js b/core/modules/quickedit/js/views/AppView.js index b09a110b8bf7..5c7d12c8ec50 100644 --- a/core/modules/quickedit/js/views/AppView.js +++ b/core/modules/quickedit/js/views/AppView.js @@ -1,91 +1,54 @@ /** - * @file - * A Backbone View that controls the overall "in-place editing application". - * - * @see Drupal.quickedit.AppModel - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/views/AppView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, _, Backbone, Drupal) { 'use strict'; - // Indicates whether the page should be reloaded after in-place editing has - // shut down. A page reload is necessary to re-instate the original HTML of - // the edited fields if in-place editing has been canceled and one or more of - // the entity's fields were saved to PrivateTempStore: one of them may have - // been changed to the empty value and hence may have been rerendered as the - // empty string, which makes it impossible for Quick Edit to know where to - // restore the original HTML. var reload = false; - Drupal.quickedit.AppView = Backbone.View.extend(/** @lends Drupal.quickedit.AppView# */{ - - /** - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * An object with the following keys: - * @param {Drupal.quickedit.AppModel} options.model - * The application state model. - * @param {Drupal.quickedit.EntityCollection} options.entitiesCollection - * All on-page entities. - * @param {Drupal.quickedit.FieldCollection} options.fieldsCollection - * All on-page fields - */ - initialize: function (options) { - // AppView's configuration for handling states. - // @see Drupal.quickedit.FieldModel.states + Drupal.quickedit.AppView = Backbone.View.extend({ + initialize: function initialize(options) { this.activeFieldStates = ['activating', 'active']; this.singleFieldStates = ['highlighted', 'activating', 'active']; this.changedFieldStates = ['changed', 'saving', 'saved', 'invalid']; this.readyFieldStates = ['candidate', 'highlighted']; - // Track app state. this.listenTo(options.entitiesCollection, 'change:state', this.appStateChange); this.listenTo(options.entitiesCollection, 'change:isActive', this.enforceSingleActiveEntity); - // Track app state. this.listenTo(options.fieldsCollection, 'change:state', this.editorStateChange); - // Respond to field model HTML representation change events. + this.listenTo(options.fieldsCollection, 'change:html', this.renderUpdatedField); this.listenTo(options.fieldsCollection, 'change:html', this.propagateUpdatedField); - // Respond to addition. + this.listenTo(options.fieldsCollection, 'add', this.rerenderedFieldToCandidate); - // Respond to destruction. + this.listenTo(options.fieldsCollection, 'destroy', this.teardownEditor); }, - /** - * Handles setup/teardown and state changes when the active entity changes. - * - * @param {Drupal.quickedit.EntityModel} entityModel - * An instance of the EntityModel class. - * @param {string} state - * The state of the associated field. One of - * {@link Drupal.quickedit.EntityModel.states}. - */ - appStateChange: function (entityModel, state) { + appStateChange: function appStateChange(entityModel, state) { var app = this; var entityToolbarView; switch (state) { case 'launching': reload = false; - // First, create an entity toolbar view. + entityToolbarView = new Drupal.quickedit.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'); }); @@ -93,17 +56,16 @@ case 'closed': entityToolbarView = entityModel.toolbarView; - // First, tear down the in-place editors. + entityModel.get('fields').each(function (fieldModel) { app.teardownEditor(fieldModel); }); - // Second, tear down the entity toolbar view. + if (entityToolbarView) { entityToolbarView.remove(); delete entityModel.toolbarView; } - // A page reload may be necessary to re-instate the original HTML of - // the edited fields. + if (reload) { reload = false; location.reload(); @@ -112,156 +74,77 @@ } }, - /** - * Accepts or reject editor (Editor) state changes. - * - * This is what ensures that the app is in control of what happens. - * - * @param {string} from - * The previous state. - * @param {string} to - * The new state. - * @param {null|object} context - * The context that is trying to trigger the state change. - * @param {Drupal.quickedit.FieldModel} fieldModel - * The fieldModel to which this change applies. - * - * @return {bool} - * Whether the editor change was accepted or rejected. - */ - acceptEditorStateChange: function (from, to, context, fieldModel) { + acceptEditorStateChange: function acceptEditorStateChange(from, to, context, fieldModel) { var accept = true; - // If the app is in view mode, then reject all state changes except for - // those to 'inactive'. if (context && (context.reason === 'stop' || context.reason === 'rerender')) { if (from === 'candidate' && to === 'inactive') { accept = true; } - } - // Handling of edit mode state changes is more granular. - else { - // In general, enforce the states sequence. Disallow going back from a - // "later" state to an "earlier" state, except in explicitly allowed - // cases. - if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) { - accept = false; - // Allow: activating/active -> candidate. - // Necessary to stop editing a field. - if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') { - accept = true; - } - // Allow: changed/invalid -> candidate. - // 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 field. - else if (from === 'highlighted' && to === 'candidate') { - accept = true; - } - // Allow: saved -> candidate. - // 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 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) { - var activeField; - var activeFieldState; - // Ensure only one field (editor) 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.readyFieldStates.indexOf(from) !== -1 || from === 'invalid') && this.activeFieldStates.indexOf(to) !== -1) { - activeField = this.model.get('activeField'); - if (activeField && activeField !== fieldModel) { - activeFieldState = activeField.get('state'); - // Allow the state change. If the state of the active field is: - // - 'activating' or 'active': change it to 'candidate' - // - 'changed' or 'invalid': change it to 'saving' - // - 'saving' or 'saved': don't do anything. - if (this.activeFieldStates.indexOf(activeFieldState) !== -1) { - activeField.set('state', 'candidate'); - } - else if (activeFieldState === 'changed' || activeFieldState === 'invalid') { - activeField.set('state', 'saving'); - } + } else { + if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) { + accept = false; - // 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('activeField', fieldModel); - accept = false; - } - // 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 - // mouseleave. - else if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') { - if (context && context.reason === 'mouseleave') { - accept = false; - } - } - // When attempting to stop editing a changed/invalid property, ask for - // confirmation. - else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { - if (context && context.reason === 'mouseleave') { - accept = false; - } - else { - // Check whether the transition has been confirmed? - if (context && context.confirmed) { + if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') { + accept = true; + } else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { accept = true; + } else if (from === 'highlighted' && to === 'candidate') { + accept = true; + } else if (from === 'saved' && to === 'candidate') { + accept = true; + } else if (from === 'invalid' && to === 'saving') { + accept = true; + } else if (from === 'invalid' && to === 'activating') { + accept = true; + } + } + + if (accept) { + var activeField; + var activeFieldState; + + if ((this.readyFieldStates.indexOf(from) !== -1 || from === 'invalid') && this.activeFieldStates.indexOf(to) !== -1) { + activeField = this.model.get('activeField'); + if (activeField && activeField !== fieldModel) { + activeFieldState = activeField.get('state'); + + if (this.activeFieldStates.indexOf(activeFieldState) !== -1) { + activeField.set('state', 'candidate'); + } else if (activeFieldState === 'changed' || activeFieldState === 'invalid') { + activeField.set('state', 'saving'); + } + + if (from === 'invalid') { + this.model.set('activeField', fieldModel); + accept = false; + } } - } + } else if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + } else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } else { + if (context && context.confirmed) { + accept = true; + } + } + } } } - } return accept; }, - /** - * Sets up the in-place editor for the given field. - * - * Must happen before the fieldModel's state is changed to 'candidate'. - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * The field for which an in-place editor must be set up. - */ - setupEditor: function (fieldModel) { - // Get the corresponding entity toolbar. + setupEditor: function setupEditor(fieldModel) { 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.quickedit.EditorModel(); var editorView = new Drupal.quickedit.editors[editorName]({ @@ -270,9 +153,6 @@ fieldModel: fieldModel }); - // Create in-place editor's toolbar for this field — stored inside the - // entity toolbar, the entity toolbar will position itself appropriately - // above (or below) the edited element. var toolbarView = new Drupal.quickedit.FieldToolbarView({ el: fieldToolbarRoot, model: fieldModel, @@ -281,77 +161,46 @@ entityModel: entityModel }); - // Create decoration for edited element: padding if necessary, sets - // classes on the element to style it according to the current state. var decorationView = new Drupal.quickedit.FieldDecorationView({ el: $(editorView.getEditedElement()), model: fieldModel, editorView: editorView }); - // Track these three views in FieldModel so that we can tear them down - // correctly. fieldModel.editorView = editorView; fieldModel.toolbarView = toolbarView; fieldModel.decorationView = decorationView; }, - /** - * Tears down the in-place editor for the given field. - * - * Must happen after the fieldModel's state is changed to 'inactive'. - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * The field for which an in-place editor must be torn down. - */ - teardownEditor: function (fieldModel) { - // Early-return if this field was not yet decorated. + teardownEditor: function teardownEditor(fieldModel) { if (typeof fieldModel.editorView === 'undefined') { return; } - // Unbind event handlers; remove toolbar element; delete toolbar view. fieldModel.toolbarView.remove(); delete fieldModel.toolbarView; - // Unbind event handlers; delete decoration view. Don't remove the element - // because that would remove the field itself. fieldModel.decorationView.remove(); delete fieldModel.decorationView; - // Unbind event handlers; delete editor view. Don't remove the element - // because that would remove the field itself. fieldModel.editorView.remove(); delete fieldModel.editorView; }, - /** - * Asks the user to confirm whether he wants to stop editing via a modal. - * - * @param {Drupal.quickedit.EntityModel} entityModel - * An instance of the EntityModel class. - * - * @see Drupal.quickedit.AppView#acceptEditorStateChange - */ - confirmEntityDeactivation: function (entityModel) { + confirmEntityDeactivation: function confirmEntityDeactivation(entityModel) { var that = this; var discardDialog; function closeDiscardDialog(action) { discardDialog.close(action); - // The active modal has been removed. + that.model.set('activeModal', null); - // If the targetState is saving, the field must be saved, then the - // entity must be saved. if (action === 'save') { - entityModel.set('state', 'committing', {confirmed: true}); - } - else { - entityModel.set('state', 'deactivating', {confirmed: true}); - // Editing has been canceled and the changes will not be saved. Mark - // the page for reload if the entityModel declares that it requires - // a reload. + entityModel.set('state', 'committing', { confirmed: true }); + } else { + entityModel.set('state', 'deactivating', { confirmed: true }); + if (entityModel.get('reload')) { reload = true; entityModel.set('reload', false); @@ -359,38 +208,31 @@ } } - // Only instantiate if there isn't a modal instance visible yet. if (!this.model.get('activeModal')) { var $unsavedChanges = $('<div>' + Drupal.t('You have unsaved changes') + '</div>'); discardDialog = Drupal.dialog($unsavedChanges.get(0), { title: Drupal.t('Discard changes?'), dialogClass: 'quickedit-discard-modal', resizable: false, - buttons: [ - { - text: Drupal.t('Save'), - click: function () { - closeDiscardDialog('save'); - }, - primary: true + buttons: [{ + text: Drupal.t('Save'), + click: function click() { + closeDiscardDialog('save'); }, - { - text: Drupal.t('Discard changes'), - click: function () { - closeDiscardDialog('discard'); - } + primary: true + }, { + text: Drupal.t('Discard changes'), + click: function click() { + closeDiscardDialog('discard'); } - ], - // Prevent this modal from being closed without the user making a - // choice as per http://stackoverflow.com/a/5438771. + }], + closeOnEscape: false, - create: function () { + create: function create() { $(this).parent().find('.ui-dialog-titlebar-close').remove(); }, beforeClose: false, - close: function (event) { - // Automatically destroy the DOM element that was used for the - // dialog. + close: function close(event) { $(event.target).remove(); } }); @@ -400,33 +242,19 @@ } }, - /** - * Reacts to field state changes; tracks global state. - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * The `fieldModel` holding the state. - * @param {string} state - * The state of the associated field. One of - * {@link Drupal.quickedit.FieldModel.states}. - */ - editorStateChange: function (fieldModel, state) { + editorStateChange: function editorStateChange(fieldModel, state) { var from = fieldModel.previous('state'); var to = state; - // Keep track of the highlighted field in the global state. if (_.indexOf(this.singleFieldStates, to) !== -1 && this.model.get('highlightedField') !== fieldModel) { this.model.set('highlightedField', fieldModel); - } - else if (this.model.get('highlightedField') === fieldModel && to === 'candidate') { + } else if (this.model.get('highlightedField') === fieldModel && to === 'candidate') { this.model.set('highlightedField', null); } - // Keep track of the active field in the global state. if (_.indexOf(this.activeFieldStates, to) !== -1 && this.model.get('activeField') !== fieldModel) { this.model.set('activeField', fieldModel); - } - else if (this.model.get('activeField') === fieldModel && to === 'candidate') { - // Discarded if it transitions from a changed state to 'candidate'. + } else if (this.model.get('activeField') === fieldModel && to === 'candidate') { if (from === 'changed' || from === 'invalid') { fieldModel.editorView.revert(); } @@ -434,167 +262,76 @@ } }, - /** - * Render an updated field (a field whose 'html' attribute changed). - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * The FieldModel whose 'html' attribute changed. - * @param {string} html - * The updated 'html' attribute. - * @param {object} options - * An object with the following keys: - * @param {bool} options.propagation - * Whether this change to the 'html' attribute occurred because of the - * propagation of changes to another instance of this field. - */ - renderUpdatedField: function (fieldModel, html, options) { - // Get data necessary to rerender property before it is unavailable. + renderUpdatedField: function renderUpdatedField(fieldModel, html, options) { var $fieldWrapper = $(fieldModel.get('el')); var $context = $fieldWrapper.parent(); - var renderField = function () { - // Destroy the field model; this will cause all attached views to be - // destroyed too, and removal from all collections in which it exists. + var renderField = function renderField() { fieldModel.destroy(); - // Replace the old content with the new content. $fieldWrapper.replaceWith(html); - // Attach behaviors again to the modified piece of HTML; this will - // create a new field model and call rerenderedFieldToCandidate() with - // it. Drupal.attachBehaviors($context.get(0)); }; - // When propagating the changes of another instance of this field, this - // field is not being actively edited and hence no state changes are - // necessary. So: only update the state of this field when the rerendering - // of this field happens not because of propagation, but because it is - // being edited itself. if (!options.propagation) { - // Deferred because renderUpdatedField is reacting to a field model - // change event, and we want to make sure that event fully propagates - // before making another change to the same model. _.defer(function () { - // First set the state to 'candidate', to allow all attached views to - // clean up all their "active state"-related changes. fieldModel.set('state', 'candidate'); - // Similarly, the above .set() call's change event must fully - // propagate before calling it again. _.defer(function () { - // Set the field's state to 'inactive', to enable the updating of - // its DOM value. - fieldModel.set('state', 'inactive', {reason: 'rerender'}); + fieldModel.set('state', 'inactive', { reason: 'rerender' }); renderField(); }); }); - } - else { + } else { renderField(); } }, - /** - * Propagates changes to an updated field to all instances of that field. - * - * @param {Drupal.quickedit.FieldModel} updatedField - * The FieldModel whose 'html' attribute changed. - * @param {string} html - * The updated 'html' attribute. - * @param {object} options - * An object with the following keys: - * @param {bool} options.propagation - * Whether this change to the 'html' attribute occurred because of the - * propagation of changes to another instance of this field. - * - * @see Drupal.quickedit.AppView#renderUpdatedField - */ - propagateUpdatedField: function (updatedField, html, options) { - // Don't propagate field updates that themselves were caused by - // propagation. + propagateUpdatedField: function propagateUpdatedField(updatedField, html, options) { if (options.propagation) { return; } var htmlForOtherViewModes = updatedField.get('htmlForOtherViewModes'); - Drupal.quickedit.collections.fields - // Find all instances of fields that display the same logical field - // (same entity, same field, just a different instance and maybe a - // different view mode). - .where({logicalFieldID: updatedField.get('logicalFieldID')}) - .forEach(function (field) { - // Ignore the field that was already updated. - if (field === updatedField) { - return; - } - // If this other instance of the field has the same view mode, we can - // update it easily. - else if (field.getViewMode() === updatedField.getViewMode()) { + Drupal.quickedit.collections.fields.where({ logicalFieldID: updatedField.get('logicalFieldID') }).forEach(function (field) { + if (field === updatedField) { + return; + } else if (field.getViewMode() === updatedField.getViewMode()) { field.set('html', updatedField.get('html')); - } - // If this other instance of the field has a different view mode, and - // that is one of the view modes for which a re-rendered version is - // available (and that should be the case unless this field was only - // added to the page after editing of the updated field began), then - // use that view mode's re-rendered version. - else { - if (field.getViewMode() in htmlForOtherViewModes) { - field.set('html', htmlForOtherViewModes[field.getViewMode()], {propagation: true}); + } else { + if (field.getViewMode() in htmlForOtherViewModes) { + field.set('html', htmlForOtherViewModes[field.getViewMode()], { propagation: true }); + } } - } - }); + }); }, - /** - * If the new in-place editable field is for the entity that's currently - * being edited, then transition it to the 'candidate' state. - * - * This happens when a field was modified, saved and hence rerendered. - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * A field that was just added to the collection of fields. - */ - rerenderedFieldToCandidate: function (fieldModel) { - var activeEntity = Drupal.quickedit.collections.entities.findWhere({isActive: true}); - - // Early-return if there is no active entity. + rerenderedFieldToCandidate: function rerenderedFieldToCandidate(fieldModel) { + var activeEntity = Drupal.quickedit.collections.entities.findWhere({ isActive: true }); + if (!activeEntity) { return; } - // If the field's entity is the active entity, make it a candidate. if (fieldModel.get('entity') === activeEntity) { this.setupEditor(fieldModel); fieldModel.set('state', 'candidate'); } }, - /** - * EntityModel Collection change handler. - * - * Handler is called `change:isActive` and enforces a single active entity. - * - * @param {Drupal.quickedit.EntityModel} changedEntityModel - * The entityModel instance whose active state has changed. - */ - enforceSingleActiveEntity: function (changedEntityModel) { - // When an entity is deactivated, we don't need to enforce anything. + enforceSingleActiveEntity: function enforceSingleActiveEntity(changedEntityModel) { if (changedEntityModel.get('isActive') === false) { return; } - // This entity was activated; deactivate all other entities. - changedEntityModel.collection.chain() - .filter(function (entityModel) { - return entityModel.get('isActive') === true && entityModel !== changedEntityModel; - }) - .each(function (entityModel) { - entityModel.set('state', 'deactivating'); - }); + changedEntityModel.collection.chain().filter(function (entityModel) { + return entityModel.get('isActive') === true && entityModel !== changedEntityModel; + }).each(function (entityModel) { + entityModel.set('state', 'deactivating'); + }); } }); - -}(jQuery, _, Backbone, Drupal)); +})(jQuery, _, Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/views/ContextualLinkView.es6.js b/core/modules/quickedit/js/views/ContextualLinkView.es6.js new file mode 100644 index 000000000000..bf50f616c7dc --- /dev/null +++ b/core/modules/quickedit/js/views/ContextualLinkView.es6.js @@ -0,0 +1,81 @@ +/** + * @file + * A Backbone View that provides a dynamic contextual link. + */ + +(function ($, Backbone, Drupal) { + + 'use strict'; + + Drupal.quickedit.ContextualLinkView = Backbone.View.extend(/** @lends Drupal.quickedit.ContextualLinkView# */{ + + /** + * Define all events to listen to. + * + * @return {object} + * A map of events. + */ + events: function () { + // Prevents delay and simulated mouse events. + function touchEndToClick(event) { + event.preventDefault(); + event.target.click(); + } + + return { + 'click a': function (event) { + event.preventDefault(); + this.model.set('state', 'launching'); + }, + 'touchEnd a': touchEndToClick + }; + }, + + /** + * Create a new contextual link view. + * + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * An object with the following keys: + * @param {Drupal.quickedit.EntityModel} options.model + * The associated entity's model. + * @param {Drupal.quickedit.AppModel} options.appModel + * The application state model. + * @param {object} options.strings + * The strings for the "Quick edit" link. + */ + initialize: function (options) { + // Insert the text of the quick edit toggle. + this.$el.find('a').text(options.strings.quickEdit); + // Initial render. + this.render(); + // Re-render whenever this entity's isActive attribute changes. + this.listenTo(this.model, 'change:isActive', this.render); + }, + + /** + * Render function for the contextual link view. + * + * @param {Drupal.quickedit.EntityModel} entityModel + * The associated `EntityModel`. + * @param {bool} isActive + * Whether the in-place editor is active or not. + * + * @return {Drupal.quickedit.ContextualLinkView} + * The `ContextualLinkView` in question. + */ + render: function (entityModel, isActive) { + this.$el.find('a').attr('aria-pressed', isActive); + + // Hides the contextual links if an in-place editor is active. + this.$el.closest('.contextual').toggle(!isActive); + + return this; + } + + }); + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/quickedit/js/views/ContextualLinkView.js b/core/modules/quickedit/js/views/ContextualLinkView.js index bf50f616c7dc..d6ea93ae6ded 100644 --- a/core/modules/quickedit/js/views/ContextualLinkView.js +++ b/core/modules/quickedit/js/views/ContextualLinkView.js @@ -1,29 +1,24 @@ /** - * @file - * A Backbone View that provides a dynamic contextual link. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/views/ContextualLinkView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Backbone, Drupal) { 'use strict'; - Drupal.quickedit.ContextualLinkView = Backbone.View.extend(/** @lends Drupal.quickedit.ContextualLinkView# */{ - - /** - * Define all events to listen to. - * - * @return {object} - * A map of events. - */ - events: function () { - // Prevents delay and simulated mouse events. + Drupal.quickedit.ContextualLinkView = Backbone.View.extend({ + events: function events() { function touchEndToClick(event) { event.preventDefault(); event.target.click(); } return { - 'click a': function (event) { + 'click a': function clickA(event) { event.preventDefault(); this.model.set('state', 'launching'); }, @@ -31,51 +26,21 @@ }; }, - /** - * Create a new contextual link view. - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * An object with the following keys: - * @param {Drupal.quickedit.EntityModel} options.model - * The associated entity's model. - * @param {Drupal.quickedit.AppModel} options.appModel - * The application state model. - * @param {object} options.strings - * The strings for the "Quick edit" link. - */ - initialize: function (options) { - // Insert the text of the quick edit toggle. + initialize: function initialize(options) { this.$el.find('a').text(options.strings.quickEdit); - // Initial render. + this.render(); - // Re-render whenever this entity's isActive attribute changes. + this.listenTo(this.model, 'change:isActive', this.render); }, - /** - * Render function for the contextual link view. - * - * @param {Drupal.quickedit.EntityModel} entityModel - * The associated `EntityModel`. - * @param {bool} isActive - * Whether the in-place editor is active or not. - * - * @return {Drupal.quickedit.ContextualLinkView} - * The `ContextualLinkView` in question. - */ - render: function (entityModel, isActive) { + render: function render(entityModel, isActive) { this.$el.find('a').attr('aria-pressed', isActive); - // Hides the contextual links if an in-place editor is active. this.$el.closest('.contextual').toggle(!isActive); return this; } }); - -})(jQuery, Backbone, Drupal); +})(jQuery, Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/views/EditorView.es6.js b/core/modules/quickedit/js/views/EditorView.es6.js new file mode 100644 index 000000000000..5e041db0d4eb --- /dev/null +++ b/core/modules/quickedit/js/views/EditorView.es6.js @@ -0,0 +1,304 @@ +/** + * @file + * An abstract Backbone View that controls an in-place editor. + */ + +(function ($, Backbone, Drupal) { + + 'use strict'; + + Drupal.quickedit.EditorView = Backbone.View.extend(/** @lends Drupal.quickedit.EditorView# */{ + + /** + * A base implementation that outlines the structure for in-place editors. + * + * Specific in-place editor implementations should subclass (extend) this + * View and override whichever method they deem necessary to override. + * + * Typically you would want to override this method to set the + * originalValue attribute in the FieldModel to such a value that your + * in-place editor can revert to the original value when necessary. + * + * @example + * <caption>If you override this method, you should call this + * method (the parent class' initialize()) first.</caption> + * Drupal.quickedit.EditorView.prototype.initialize.call(this, options); + * + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * An object with the following keys: + * @param {Drupal.quickedit.EditorModel} options.model + * The in-place editor state model. + * @param {Drupal.quickedit.FieldModel} options.fieldModel + * The field model. + * + * @see Drupal.quickedit.EditorModel + * @see Drupal.quickedit.editors.plain_text + */ + initialize: function (options) { + this.fieldModel = options.fieldModel; + this.listenTo(this.fieldModel, 'change:state', this.stateChange); + }, + + /** + * @inheritdoc + */ + remove: function () { + // The el property is the field, which should not be removed. Remove the + // pointer to it, then call Backbone.View.prototype.remove(). + this.setElement(); + Backbone.View.prototype.remove.call(this); + }, + + /** + * Returns the edited element. + * + * For some single cardinality fields, it may be necessary or useful to + * not in-place edit (and hence decorate) the DOM element with the + * data-quickedit-field-id attribute (which is the field's wrapper), but a + * specific element within the field's wrapper. + * e.g. using a WYSIWYG editor on a body field should happen on the DOM + * element containing the text itself, not on the field wrapper. + * + * @return {jQuery} + * A jQuery-wrapped DOM element. + * + * @see Drupal.quickedit.editors.plain_text + */ + getEditedElement: function () { + return this.$el; + }, + + /** + * + * @return {object} + * Returns 3 Quick Edit UI settings that depend on the in-place editor: + * - Boolean padding: indicates whether padding should be applied to the + * edited element, to guarantee legibility of text. + * - Boolean unifiedToolbar: provides the in-place editor with the ability + * to insert its own toolbar UI into Quick Edit's tightly integrated + * toolbar. + * - Boolean fullWidthToolbar: indicates whether Quick Edit's tightly + * integrated toolbar should consume the full width of the element, + * rather than being just long enough to accommodate a label. + */ + getQuickEditUISettings: function () { + return {padding: false, unifiedToolbar: false, fullWidthToolbar: false, popup: false}; + }, + + /** + * Determines the actions to take given a change of state. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The quickedit `FieldModel` that holds the state. + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.FieldModel.states}. + */ + stateChange: function (fieldModel, state) { + var from = fieldModel.previous('state'); + var to = state; + switch (to) { + case 'inactive': + // An in-place editor view will not yet exist in this state, hence + // this will never be reached. Listed for sake of completeness. + break; + + case 'candidate': + // Nothing to do for the typical in-place editor: it should not be + // visible yet. Except when we come from the 'invalid' state, then we + // clean up. + if (from === 'invalid') { + this.removeValidationErrors(); + } + break; + + case 'highlighted': + // Nothing to do for the typical in-place editor: it should not be + // visible yet. + break; + + case 'activating': + // The user has indicated he wants to do in-place editing: if + // something needs to be loaded (CSS/JavaScript/server data/…), then + // do so at this stage, and once the in-place editor is ready, + // set the 'active' state. A "loading" indicator will be shown in the + // UI for as long as the field remains in this state. + var loadDependencies = function (callback) { + // Do the loading here. + callback(); + }; + loadDependencies(function () { + fieldModel.set('state', 'active'); + }); + break; + + case 'active': + // The user can now actually use the in-place editor. + break; + + case 'changed': + // Nothing to do for the typical in-place editor. The UI will show an + // indicator that the field has changed. + break; + + case 'saving': + // When the user has indicated he wants to save his changes to this + // field, this state will be entered. If the previous saving attempt + // resulted in validation errors, the previous state will be + // 'invalid'. Clean up those validation errors while the user is + // saving. + if (from === 'invalid') { + this.removeValidationErrors(); + } + this.save(); + break; + + case 'saved': + // Nothing to do for the typical in-place editor. Immediately after + // being saved, a field will go to the 'candidate' state, where it + // should no longer be visible (after all, the field will then again + // just be a *candidate* to be in-place edited). + break; + + case 'invalid': + // The modified field value was attempted to be saved, but there were + // validation errors. + this.showValidationErrors(); + break; + } + }, + + /** + * Reverts the modified value to the original, before editing started. + */ + revert: function () { + // A no-op by default; each editor should implement reverting itself. + // Note that if the in-place editor does not cause the FieldModel's + // element to be modified, then nothing needs to happen. + }, + + /** + * Saves the modified value in the in-place editor for this field. + */ + save: function () { + var fieldModel = this.fieldModel; + var editorModel = this.model; + var backstageId = 'quickedit_backstage-' + this.fieldModel.id.replace(/[\/\[\]\_\s]/g, '-'); + + function fillAndSubmitForm(value) { + var $form = $('#' + backstageId).find('form'); + // Fill in the value in any <input> that isn't hidden or a submit + // button. + $form.find(':input[type!="hidden"][type!="submit"]:not(select)') + // Don't mess with the node summary. + .not('[name$="\\[summary\\]"]').val(value); + // Submit the form. + $form.find('.quickedit-form-submit').trigger('click.quickedit'); + } + + var formOptions = { + fieldID: this.fieldModel.get('fieldID'), + $el: this.$el, + nocssjs: true, + other_view_modes: fieldModel.findOtherViewModes(), + // Reset an existing entry for this entity in the PrivateTempStore (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') + }; + + var self = this; + Drupal.quickedit.util.form.load(formOptions, function (form, ajax) { + // Create a backstage area for storing forms that are hidden from view + // (hence "backstage" — since the editing doesn't happen in the form, it + // happens "directly" in the content, the form is only used for saving). + var $backstage = $(Drupal.theme('quickeditBackstage', {id: backstageId})).appendTo('body'); + // Hidden forms are stuffed into the backstage container for this field. + var $form = $(form).appendTo($backstage); + // Disable the browser's HTML5 validation; we only care about server- + // side validation. (Not disabling this will actually cause problems + // because browsers don't like to set HTML5 validation errors on hidden + // forms.) + $form.prop('novalidate', true); + var $submit = $form.find('.quickedit-form-submit'); + self.formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving(formOptions, $submit); + + function removeHiddenForm() { + Drupal.quickedit.util.form.unajaxifySaving(self.formSaveAjax); + delete self.formSaveAjax; + $backstage.remove(); + } + + // Successfully saved. + self.formSaveAjax.commands.quickeditFieldFormSaved = function (ajax, response, status) { + removeHiddenForm(); + // First, transition the state to 'saved'. + fieldModel.set('state', 'saved'); + // Second, set the 'htmlForOtherViewModes' attribute, so that when + // this field is rerendered, the change can be propagated to other + // instances of this field, which may be displayed in different view + // modes. + fieldModel.set('htmlForOtherViewModes', response.other_view_modes); + // Finally, 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. + self.formSaveAjax.commands.quickeditFieldFormValidationErrors = function (ajax, response, status) { + removeHiddenForm(); + editorModel.set('validationErrors', response.data); + fieldModel.set('state', 'invalid'); + }; + + // The quickeditFieldForm AJAX command is only called upon loading the + // form for the first time, and when there are validation errors in the + // form; Form API then marks which form items have errors. This is + // useful for 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. + self.formSaveAjax.commands.quickeditFieldForm = function () {}; + + fillAndSubmitForm(editorModel.get('currentValue')); + }); + }, + + /** + * Shows validation error messages. + * + * Should be called when the state is changed to 'invalid'. + */ + showValidationErrors: function () { + var $errors = $('<div class="quickedit-validation-errors"></div>') + .append(this.model.get('validationErrors')); + this.getEditedElement() + .addClass('quickedit-validation-error') + .after($errors); + }, + + /** + * Cleans up validation error messages. + * + * Should be called when the state is changed to 'candidate' or 'saving'. In + * the case of the latter: the user has modified the value in the in-place + * editor again to attempt to save again. In the case of the latter: the + * invalid value was discarded. + */ + removeValidationErrors: function () { + this.getEditedElement() + .removeClass('quickedit-validation-error') + .next('.quickedit-validation-errors') + .remove(); + } + + }); + +}(jQuery, Backbone, Drupal)); diff --git a/core/modules/quickedit/js/views/EditorView.js b/core/modules/quickedit/js/views/EditorView.js index 5e041db0d4eb..c0456ac99f3a 100644 --- a/core/modules/quickedit/js/views/EditorView.js +++ b/core/modules/quickedit/js/views/EditorView.js @@ -1,134 +1,52 @@ /** - * @file - * An abstract Backbone View that controls an in-place editor. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/views/EditorView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Backbone, Drupal) { 'use strict'; - Drupal.quickedit.EditorView = Backbone.View.extend(/** @lends Drupal.quickedit.EditorView# */{ - - /** - * A base implementation that outlines the structure for in-place editors. - * - * Specific in-place editor implementations should subclass (extend) this - * View and override whichever method they deem necessary to override. - * - * Typically you would want to override this method to set the - * originalValue attribute in the FieldModel to such a value that your - * in-place editor can revert to the original value when necessary. - * - * @example - * <caption>If you override this method, you should call this - * method (the parent class' initialize()) first.</caption> - * Drupal.quickedit.EditorView.prototype.initialize.call(this, options); - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * An object with the following keys: - * @param {Drupal.quickedit.EditorModel} options.model - * The in-place editor state model. - * @param {Drupal.quickedit.FieldModel} options.fieldModel - * The field model. - * - * @see Drupal.quickedit.EditorModel - * @see Drupal.quickedit.editors.plain_text - */ - initialize: function (options) { + Drupal.quickedit.EditorView = Backbone.View.extend({ + initialize: function initialize(options) { this.fieldModel = options.fieldModel; this.listenTo(this.fieldModel, 'change:state', this.stateChange); }, - /** - * @inheritdoc - */ - remove: function () { - // The el property is the field, which should not be removed. Remove the - // pointer to it, then call Backbone.View.prototype.remove(). + remove: function remove() { this.setElement(); Backbone.View.prototype.remove.call(this); }, - /** - * Returns the edited element. - * - * For some single cardinality fields, it may be necessary or useful to - * not in-place edit (and hence decorate) the DOM element with the - * data-quickedit-field-id attribute (which is the field's wrapper), but a - * specific element within the field's wrapper. - * e.g. using a WYSIWYG editor on a body field should happen on the DOM - * element containing the text itself, not on the field wrapper. - * - * @return {jQuery} - * A jQuery-wrapped DOM element. - * - * @see Drupal.quickedit.editors.plain_text - */ - getEditedElement: function () { + getEditedElement: function getEditedElement() { return this.$el; }, - /** - * - * @return {object} - * Returns 3 Quick Edit UI settings that depend on the in-place editor: - * - Boolean padding: indicates whether padding should be applied to the - * edited element, to guarantee legibility of text. - * - Boolean unifiedToolbar: provides the in-place editor with the ability - * to insert its own toolbar UI into Quick Edit's tightly integrated - * toolbar. - * - Boolean fullWidthToolbar: indicates whether Quick Edit's tightly - * integrated toolbar should consume the full width of the element, - * rather than being just long enough to accommodate a label. - */ - getQuickEditUISettings: function () { - return {padding: false, unifiedToolbar: false, fullWidthToolbar: false, popup: false}; + getQuickEditUISettings: function getQuickEditUISettings() { + return { padding: false, unifiedToolbar: false, fullWidthToolbar: false, popup: false }; }, - /** - * Determines the actions to take given a change of state. - * - * @param {Drupal.quickedit.FieldModel} fieldModel - * The quickedit `FieldModel` that holds the state. - * @param {string} state - * The state of the associated field. One of - * {@link Drupal.quickedit.FieldModel.states}. - */ - stateChange: function (fieldModel, state) { + stateChange: function stateChange(fieldModel, state) { var from = fieldModel.previous('state'); var to = state; switch (to) { case 'inactive': - // An in-place editor view will not yet exist in this state, hence - // this will never be reached. Listed for sake of completeness. break; case 'candidate': - // Nothing to do for the typical in-place editor: it should not be - // visible yet. Except when we come from the 'invalid' state, then we - // clean up. if (from === 'invalid') { this.removeValidationErrors(); } break; case 'highlighted': - // Nothing to do for the typical in-place editor: it should not be - // visible yet. break; case 'activating': - // The user has indicated he wants to do in-place editing: if - // something needs to be loaded (CSS/JavaScript/server data/…), then - // do so at this stage, and once the in-place editor is ready, - // set the 'active' state. A "loading" indicator will be shown in the - // UI for as long as the field remains in this state. - var loadDependencies = function (callback) { - // Do the loading here. + var loadDependencies = function loadDependencies(callback) { callback(); }; loadDependencies(function () { @@ -137,20 +55,12 @@ break; case 'active': - // The user can now actually use the in-place editor. break; case 'changed': - // Nothing to do for the typical in-place editor. The UI will show an - // indicator that the field has changed. break; case 'saving': - // When the user has indicated he wants to save his changes to this - // field, this state will be entered. If the previous saving attempt - // resulted in validation errors, the previous state will be - // 'invalid'. Clean up those validation errors while the user is - // saving. if (from === 'invalid') { this.removeValidationErrors(); } @@ -158,45 +68,26 @@ break; case 'saved': - // Nothing to do for the typical in-place editor. Immediately after - // being saved, a field will go to the 'candidate' state, where it - // should no longer be visible (after all, the field will then again - // just be a *candidate* to be in-place edited). break; case 'invalid': - // The modified field value was attempted to be saved, but there were - // validation errors. this.showValidationErrors(); break; } }, - /** - * Reverts the modified value to the original, before editing started. - */ - revert: function () { - // A no-op by default; each editor should implement reverting itself. - // Note that if the in-place editor does not cause the FieldModel's - // element to be modified, then nothing needs to happen. - }, + revert: function revert() {}, - /** - * Saves the modified value in the in-place editor for this field. - */ - save: function () { + save: function save() { var fieldModel = this.fieldModel; var editorModel = this.model; var backstageId = 'quickedit_backstage-' + this.fieldModel.id.replace(/[\/\[\]\_\s]/g, '-'); function fillAndSubmitForm(value) { var $form = $('#' + backstageId).find('form'); - // Fill in the value in any <input> that isn't hidden or a submit - // button. - $form.find(':input[type!="hidden"][type!="submit"]:not(select)') - // Don't mess with the node summary. - .not('[name$="\\[summary\\]"]').val(value); - // Submit the form. + + $form.find(':input[type!="hidden"][type!="submit"]:not(select)').not('[name$="\\[summary\\]"]').val(value); + $form.find('.quickedit-form-submit').trigger('click.quickedit'); } @@ -205,28 +96,16 @@ $el: this.$el, nocssjs: true, other_view_modes: fieldModel.findOtherViewModes(), - // Reset an existing entry for this entity in the PrivateTempStore (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') }; var self = this; Drupal.quickedit.util.form.load(formOptions, function (form, ajax) { - // Create a backstage area for storing forms that are hidden from view - // (hence "backstage" — since the editing doesn't happen in the form, it - // happens "directly" in the content, the form is only used for saving). - var $backstage = $(Drupal.theme('quickeditBackstage', {id: backstageId})).appendTo('body'); - // Hidden forms are stuffed into the backstage container for this field. + var $backstage = $(Drupal.theme('quickeditBackstage', { id: backstageId })).appendTo('body'); + var $form = $(form).appendTo($backstage); - // Disable the browser's HTML5 validation; we only care about server- - // side validation. (Not disabling this will actually cause problems - // because browsers don't like to set HTML5 validation errors on hidden - // forms.) + $form.prop('novalidate', true); var $submit = $form.find('.quickedit-form-submit'); self.formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving(formOptions, $submit); @@ -237,68 +116,36 @@ $backstage.remove(); } - // Successfully saved. self.formSaveAjax.commands.quickeditFieldFormSaved = function (ajax, response, status) { removeHiddenForm(); - // First, transition the state to 'saved'. + fieldModel.set('state', 'saved'); - // Second, set the 'htmlForOtherViewModes' attribute, so that when - // this field is rerendered, the change can be propagated to other - // instances of this field, which may be displayed in different view - // modes. + fieldModel.set('htmlForOtherViewModes', response.other_view_modes); - // Finally, 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. self.formSaveAjax.commands.quickeditFieldFormValidationErrors = function (ajax, response, status) { removeHiddenForm(); editorModel.set('validationErrors', response.data); fieldModel.set('state', 'invalid'); }; - // The quickeditFieldForm AJAX command is only called upon loading the - // form for the first time, and when there are validation errors in the - // form; Form API then marks which form items have errors. This is - // useful for 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. self.formSaveAjax.commands.quickeditFieldForm = function () {}; fillAndSubmitForm(editorModel.get('currentValue')); }); }, - /** - * Shows validation error messages. - * - * Should be called when the state is changed to 'invalid'. - */ - showValidationErrors: function () { - var $errors = $('<div class="quickedit-validation-errors"></div>') - .append(this.model.get('validationErrors')); - this.getEditedElement() - .addClass('quickedit-validation-error') - .after($errors); + showValidationErrors: function showValidationErrors() { + var $errors = $('<div class="quickedit-validation-errors"></div>').append(this.model.get('validationErrors')); + this.getEditedElement().addClass('quickedit-validation-error').after($errors); }, - /** - * Cleans up validation error messages. - * - * Should be called when the state is changed to 'candidate' or 'saving'. In - * the case of the latter: the user has modified the value in the in-place - * editor again to attempt to save again. In the case of the latter: the - * invalid value was discarded. - */ - removeValidationErrors: function () { - this.getEditedElement() - .removeClass('quickedit-validation-error') - .next('.quickedit-validation-errors') - .remove(); + removeValidationErrors: function removeValidationErrors() { + this.getEditedElement().removeClass('quickedit-validation-error').next('.quickedit-validation-errors').remove(); } }); - -}(jQuery, Backbone, Drupal)); +})(jQuery, Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/views/EntityDecorationView.es6.js b/core/modules/quickedit/js/views/EntityDecorationView.es6.js new file mode 100644 index 000000000000..ff090fe4c763 --- /dev/null +++ b/core/modules/quickedit/js/views/EntityDecorationView.es6.js @@ -0,0 +1,40 @@ +/** + * @file + * A Backbone view that decorates the in-place editable entity. + */ + +(function (Drupal, $, Backbone) { + + 'use strict'; + + Drupal.quickedit.EntityDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityDecorationView# */{ + + /** + * Associated with the DOM root node of an editable entity. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.listenTo(this.model, 'change', this.render); + }, + + /** + * @inheritdoc + */ + render: function () { + this.$el.toggleClass('quickedit-entity-active', this.model.get('isActive')); + }, + + /** + * @inheritdoc + */ + remove: function () { + this.setElement(null); + Backbone.View.prototype.remove.call(this); + } + + }); + +}(Drupal, jQuery, Backbone)); diff --git a/core/modules/quickedit/js/views/EntityDecorationView.js b/core/modules/quickedit/js/views/EntityDecorationView.js index ff090fe4c763..9233696db32c 100644 --- a/core/modules/quickedit/js/views/EntityDecorationView.js +++ b/core/modules/quickedit/js/views/EntityDecorationView.js @@ -1,40 +1,28 @@ /** - * @file - * A Backbone view that decorates the in-place editable entity. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/views/EntityDecorationView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Drupal, $, Backbone) { 'use strict'; - Drupal.quickedit.EntityDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityDecorationView# */{ - - /** - * Associated with the DOM root node of an editable entity. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + Drupal.quickedit.EntityDecorationView = Backbone.View.extend({ + initialize: function initialize() { this.listenTo(this.model, 'change', this.render); }, - /** - * @inheritdoc - */ - render: function () { + render: function render() { this.$el.toggleClass('quickedit-entity-active', this.model.get('isActive')); }, - /** - * @inheritdoc - */ - remove: function () { + remove: function remove() { this.setElement(null); Backbone.View.prototype.remove.call(this); } }); - -}(Drupal, jQuery, Backbone)); +})(Drupal, jQuery, Backbone); \ No newline at end of file diff --git a/core/modules/quickedit/js/views/EntityToolbarView.es6.js b/core/modules/quickedit/js/views/EntityToolbarView.es6.js new file mode 100644 index 000000000000..4fa0506f5382 --- /dev/null +++ b/core/modules/quickedit/js/views/EntityToolbarView.es6.js @@ -0,0 +1,528 @@ +/** + * @file + * A Backbone View that provides an entity level toolbar. + */ + +(function ($, _, Backbone, Drupal, debounce) { + + 'use strict'; + + Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{ + + /** + * @type {jQuery} + */ + _fieldToolbarRoot: null, + + /** + * @return {object} + * A map of events. + */ + events: function () { + var map = { + 'click button.action-save': 'onClickSave', + 'click button.action-cancel': 'onClickCancel', + 'mouseenter': 'onMouseenter' + }; + return map; + }, + + /** + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * Options to construct the view. + * @param {Drupal.quickedit.AppModel} options.appModel + * A quickedit `AppModel` to use in the view. + */ + initialize: function (options) { + var that = this; + this.appModel = options.appModel; + this.$entity = $(this.model.get('el')); + + // Rerender whenever the entity state changes. + this.listenTo(this.model, 'change:isActive change:isDirty change:state', this.render); + // Also rerender whenever a different field is highlighted or activated. + this.listenTo(this.appModel, 'change:highlightedField change:activeField', this.render); + // Rerender when a field of the entity changes state. + this.listenTo(this.model.get('fields'), 'change:state', this.fieldStateChange); + + // Reposition the entity toolbar as the viewport and the position within + // the viewport changes. + $(window).on('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit', debounce($.proxy(this.windowChangeHandler, this), 150)); + + // Adjust the fence placement within which the entity toolbar may be + // positioned. + $(document).on('drupalViewportOffsetChange.quickedit', function (event, offsets) { + if (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('.quickedit-toolbar-field').get(0); + + // Initial render. + this.render(); + }, + + /** + * @inheritdoc + * + * @return {Drupal.quickedit.EntityToolbarView} + * The entity toolbar view. + */ + render: function () { + if (this.model.get('isActive')) { + // If the toolbar container doesn't exist, create it. + var $body = $('body'); + if ($body.children('#quickedit-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. + if ($body.children('#quickedit-toolbar-fence').length === 0) { + this.$fence = $(Drupal.theme('quickeditEntityToolbarFence')) + .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('.quickedit-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 () { + // Remove additional DOM elements controlled by this View. + this.$fence.remove(); + + // Stop listening to additional events. + $(window).off('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit'); + $(document).off('drupalViewportOffsetChange.quickedit'); + + Backbone.View.prototype.remove.call(this); + }, + + /** + * Repositions the entity toolbar on window scroll and resize. + * + * @param {jQuery.Event} event + * The scroll or resize event. + */ + windowChangeHandler: function (event) { + this.position(); + }, + + /** + * Determines the actions to take given a change of state. + * + * @param {Drupal.quickedit.FieldModel} model + * The `FieldModel` model. + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.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 {HTMLElement} [element] + * 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; + // When positioned against an active field that has padding, we should + // ignore that padding when positioning the toolbar, to not unnecessarily + // move the toolbar horizontally, which feels annoying. + var horizontalPadding = 0; + var of; + var activeField; + var 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.quickedit.app.model.get('activeField'); + of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.quickedit-form'); + break; + + case 2: + // Position against an active field. + of = activeField && activeField.editorView && activeField.editorView.getEditedElement(); + if (activeField && activeField.editorView && activeField.editorView.getQuickEditUISettings().padding) { + horizontalPadding = 5; + } + break; + + case 3: + // Position against a highlighted field. + highlightedField = Drupal.quickedit.app.model.get('highlightedField'); + of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement(); + delay = 250; + break; + + default: + var fieldModels = this.model.get('fields').models; + var topMostPosition = 1000000; + var topMostField = null; + // Position against the topmost field. + for (var i = 0; i < fieldModels.length; i++) { + var pos = fieldModels[i].get('el').getBoundingClientRect().top; + if (pos < topMostPosition) { + topMostPosition = pos; + topMostField = fieldModels[i]; + } + } + of = topMostField.get('el'); + delay = 50; + break; + } + // Prepare to check the next possible element to position against. + check++; + } while (!of); + + /** + * Refines the positioning algorithm of jquery.ui.position(). + * + * Invoked as the 'using' callback of jquery.ui.position() in + * positionToolbar(). + * + * @param {*} view + * The view the positions will be calculated from. + * @param {object} suggested + * A hash of top and left values for the position that should be set. It + * can be forwarded to .css() or .animate(). + * @param {object} info + * The position and dimensions of both the 'my' element and the 'of' + * elements, as well as calculations to their relative position. This + * object contains the following properties: + * @param {object} info.element + * A hash that contains information about the HTML element that will be + * positioned. Also known as the 'my' element. + * @param {object} info.target + * A hash that contains information about the HTML element that the + * 'my' element will be positioned against. Also known as the 'of' + * element. + */ + function refinePosition(view, suggested, info) { + // Determine if the pointer should be on the top or bottom. + var isBelow = suggested.top > info.target.top; + info.element.element.toggleClass('quickedit-toolbar-pointer-top', isBelow); + // Don't position the toolbar past the first or last editable field if + // the entity is the target. + if (view.$entity[0] === info.target.element[0]) { + // Get the first or last field according to whether the toolbar is + // above or below the entity. + var $field = view.$entity.find('.quickedit-editable').eq((isBelow) ? -1 : 0); + if ($field.length > 0) { + suggested.top = (isBelow) ? ($field.offset().top + $field.outerHeight(true)) : $field.offset().top - info.element.element.outerHeight(true); + } + } + // Don't let the toolbar go outside the fence. + var fenceTop = view.$fence.offset().top; + var fenceHeight = view.$fence.height(); + var toolbarHeight = info.element.element.outerHeight(true); + if (suggested.top < fenceTop) { + suggested.top = fenceTop; + } + else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) { + suggested.top = fenceTop + fenceHeight - toolbarHeight; + } + // Position the toolbar. + info.element.element.css({ + left: Math.floor(suggested.left), + top: Math.floor(suggested.top) + }); + } + + /** + * Calls the jquery.ui.position() method on the $el of this view. + */ + function positionToolbar() { + that.$el + .position({ + my: edge + ' bottom', + // Move the toolbar 1px towards the start edge of the 'of' element, + // plus any horizontal padding that may have been added to the + // element that is being added, to prevent unwanted horizontal + // movement. + at: edge + '+' + (1 + horizontalPadding) + ' top', + of: of, + collision: 'flipfit', + using: refinePosition.bind(null, that), + within: that.$fence + }) + // Resize the toolbar to match the dimensions of the field, up to a + // maximum width that is equal to 90% of the field's width. + .css({ + 'max-width': (document.documentElement.clientWidth < 450) ? document.documentElement.clientWidth : 450, + // 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%' + }); + } + + // 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(positionToolbar); + }, delay); + }, + + /** + * Set the model state to 'saving' when the save button is clicked. + * + * @param {jQuery.Event} event + * The click 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} event + * The click 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} event + * The mouse event. + */ + onMouseenter: function (event) { + clearTimeout(this.timer); + }, + + /** + * Builds the entity toolbar HTML; attaches to DOM; sets starting position. + * + * @return {jQuery} + * The toolbar element. + */ + buildToolbarEl: function () { + var $toolbar = $(Drupal.theme('quickeditEntityToolbar', { + id: 'quickedit-entity-toolbar' + })); + + $toolbar + .find('.quickedit-toolbar-entity') + // Append the "ops" toolgroup into the toolbar. + .prepend(Drupal.theme('quickeditToolgroup', { + classes: ['ops'], + buttons: [ + { + label: Drupal.t('Save'), + type: 'submit', + classes: 'action-save quickedit-button icon', + attributes: { + 'aria-hidden': true + } + }, + { + label: Drupal.t('Close'), + classes: 'action-cancel quickedit-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 = ''; + var entityLabel = this.model.get('label'); + + // Label of an active field, if it exists. + var activeField = Drupal.quickedit.app.model.get('activeField'); + var activeFieldLabel = activeField && activeField.get('metadata').label; + // Label of a highlighted field, if it exists. + var highlightedField = Drupal.quickedit.app.model.get('highlightedField'); + var highlightedFieldLabel = highlightedField && highlightedField.get('metadata').label; + // The label is constructed in a priority order. + if (activeFieldLabel) { + label = Drupal.theme('quickeditEntityToolbarLabel', { + entityLabel: entityLabel, + fieldLabel: activeFieldLabel + }); + } + else if (highlightedFieldLabel) { + label = Drupal.theme('quickeditEntityToolbarLabel', { + entityLabel: entityLabel, + fieldLabel: highlightedFieldLabel + }); + } + else { + // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437 + label = Drupal.checkPlain(entityLabel); + } + + this.$el + .find('.quickedit-toolbar-label') + .html(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('.quickedit-toolbar .quickedit-toolgroup.' + toolgroup); + }, + + /** + * Shows a toolgroup. + * + * @param {string} toolgroup + * A toolgroup name. + */ + show: function (toolgroup) { + this.$el.removeClass('quickedit-animate-invisible'); + } + + }); + +})(jQuery, _, Backbone, Drupal, Drupal.debounce); diff --git a/core/modules/quickedit/js/views/EntityToolbarView.js b/core/modules/quickedit/js/views/EntityToolbarView.js index 4fa0506f5382..21ca0c387eee 100644 --- a/core/modules/quickedit/js/views/EntityToolbarView.js +++ b/core/modules/quickedit/js/views/EntityToolbarView.js @@ -1,24 +1,19 @@ /** - * @file - * A Backbone View that provides an entity level toolbar. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/views/EntityToolbarView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, _, Backbone, Drupal, debounce) { 'use strict'; - Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{ - - /** - * @type {jQuery} - */ + Drupal.quickedit.EntityToolbarView = Backbone.View.extend({ _fieldToolbarRoot: null, - /** - * @return {object} - * A map of events. - */ - events: function () { + events: function events() { var map = { 'click button.action-save': 'onClickSave', 'click button.action-cancel': 'onClickCancel', @@ -27,102 +22,60 @@ return map; }, - /** - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options to construct the view. - * @param {Drupal.quickedit.AppModel} options.appModel - * A quickedit `AppModel` to use in the view. - */ - initialize: function (options) { + initialize: function initialize(options) { var that = this; this.appModel = options.appModel; this.$entity = $(this.model.get('el')); - // Rerender whenever the entity state changes. this.listenTo(this.model, 'change:isActive change:isDirty change:state', this.render); - // Also rerender whenever a different field is highlighted or activated. + this.listenTo(this.appModel, 'change:highlightedField change:activeField', this.render); - // Rerender when a field of the entity changes state. + this.listenTo(this.model.get('fields'), 'change:state', this.fieldStateChange); - // Reposition the entity toolbar as the viewport and the position within - // the viewport changes. $(window).on('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit', debounce($.proxy(this.windowChangeHandler, this), 150)); - // Adjust the fence placement within which the entity toolbar may be - // positioned. $(document).on('drupalViewportOffsetChange.quickedit', function (event, offsets) { if (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('.quickedit-toolbar-field').get(0); - // Initial render. this.render(); }, - /** - * @inheritdoc - * - * @return {Drupal.quickedit.EntityToolbarView} - * The entity toolbar view. - */ - render: function () { + render: function render() { if (this.model.get('isActive')) { - // If the toolbar container doesn't exist, create it. var $body = $('body'); if ($body.children('#quickedit-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. + if ($body.children('#quickedit-toolbar-fence').length === 0) { - this.$fence = $(Drupal.theme('quickeditEntityToolbarFence')) - .css(Drupal.displace()) - .appendTo($body); + this.$fence = $(Drupal.theme('quickeditEntityToolbarFence')).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('.quickedit-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); + $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'); + $button.addClass('action-saving icon-throbber icon-end').text(Drupal.t('Saving')).attr('disabled', 'disabled'); break; default: @@ -133,40 +86,20 @@ return this; }, - /** - * @inheritdoc - */ - remove: function () { - // Remove additional DOM elements controlled by this View. + remove: function remove() { this.$fence.remove(); - // Stop listening to additional events. $(window).off('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit'); $(document).off('drupalViewportOffsetChange.quickedit'); Backbone.View.prototype.remove.call(this); }, - /** - * Repositions the entity toolbar on window scroll and resize. - * - * @param {jQuery.Event} event - * The scroll or resize event. - */ - windowChangeHandler: function (event) { + windowChangeHandler: function windowChangeHandler(event) { this.position(); }, - /** - * Determines the actions to take given a change of state. - * - * @param {Drupal.quickedit.FieldModel} model - * The `FieldModel` model. - * @param {string} state - * The state of the associated field. One of - * {@link Drupal.quickedit.FieldModel.states}. - */ - fieldStateChange: function (model, state) { + fieldStateChange: function fieldStateChange(model, state) { switch (state) { case 'active': this.render(); @@ -178,48 +111,34 @@ } }, - /** - * Uses the jQuery.ui.position() method to position the entity toolbar. - * - * @param {HTMLElement} [element] - * The element against which the entity toolbar is positioned. - */ - position: function (element) { + position: function position(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 edge = document.documentElement.dir === 'rtl' ? 'right' : 'left'; + var delay = 0; - // Determines what check in the series of checks below should be - // evaluated. + var check = 0; - // When positioned against an active field that has padding, we should - // ignore that padding when positioning the toolbar, to not unnecessarily - // move the toolbar horizontally, which feels annoying. + var horizontalPadding = 0; var of; var activeField; var 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.quickedit.app.model.get('activeField'); of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.quickedit-form'); break; case 2: - // Position against an active field. of = activeField && activeField.editorView && activeField.editorView.getEditedElement(); if (activeField && activeField.editorView && activeField.editorView.getQuickEditUISettings().padding) { horizontalPadding = 5; @@ -227,7 +146,6 @@ break; case 3: - // Position against a highlighted field. highlightedField = Drupal.quickedit.app.model.get('highlightedField'); of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement(); delay = 250; @@ -237,7 +155,7 @@ var fieldModels = this.model.get('fields').models; var topMostPosition = 1000000; var topMostField = null; - // Position against the topmost field. + for (var i = 0; i < fieldModels.length; i++) { var pos = fieldModels[i].get('el').getBoundingClientRect().top; if (pos < topMostPosition) { @@ -249,280 +167,148 @@ delay = 50; break; } - // Prepare to check the next possible element to position against. + check++; } while (!of); - /** - * Refines the positioning algorithm of jquery.ui.position(). - * - * Invoked as the 'using' callback of jquery.ui.position() in - * positionToolbar(). - * - * @param {*} view - * The view the positions will be calculated from. - * @param {object} suggested - * A hash of top and left values for the position that should be set. It - * can be forwarded to .css() or .animate(). - * @param {object} info - * The position and dimensions of both the 'my' element and the 'of' - * elements, as well as calculations to their relative position. This - * object contains the following properties: - * @param {object} info.element - * A hash that contains information about the HTML element that will be - * positioned. Also known as the 'my' element. - * @param {object} info.target - * A hash that contains information about the HTML element that the - * 'my' element will be positioned against. Also known as the 'of' - * element. - */ function refinePosition(view, suggested, info) { - // Determine if the pointer should be on the top or bottom. var isBelow = suggested.top > info.target.top; info.element.element.toggleClass('quickedit-toolbar-pointer-top', isBelow); - // Don't position the toolbar past the first or last editable field if - // the entity is the target. + if (view.$entity[0] === info.target.element[0]) { - // Get the first or last field according to whether the toolbar is - // above or below the entity. - var $field = view.$entity.find('.quickedit-editable').eq((isBelow) ? -1 : 0); + var $field = view.$entity.find('.quickedit-editable').eq(isBelow ? -1 : 0); if ($field.length > 0) { - suggested.top = (isBelow) ? ($field.offset().top + $field.outerHeight(true)) : $field.offset().top - info.element.element.outerHeight(true); + suggested.top = isBelow ? $field.offset().top + $field.outerHeight(true) : $field.offset().top - info.element.element.outerHeight(true); } } - // Don't let the toolbar go outside the fence. + var fenceTop = view.$fence.offset().top; var fenceHeight = view.$fence.height(); var toolbarHeight = info.element.element.outerHeight(true); if (suggested.top < fenceTop) { suggested.top = fenceTop; - } - else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) { + } else if (suggested.top + toolbarHeight > fenceTop + fenceHeight) { suggested.top = fenceTop + fenceHeight - toolbarHeight; } - // Position the toolbar. + info.element.element.css({ left: Math.floor(suggested.left), top: Math.floor(suggested.top) }); } - /** - * Calls the jquery.ui.position() method on the $el of this view. - */ function positionToolbar() { - that.$el - .position({ - my: edge + ' bottom', - // Move the toolbar 1px towards the start edge of the 'of' element, - // plus any horizontal padding that may have been added to the - // element that is being added, to prevent unwanted horizontal - // movement. - at: edge + '+' + (1 + horizontalPadding) + ' top', - of: of, - collision: 'flipfit', - using: refinePosition.bind(null, that), - within: that.$fence - }) - // Resize the toolbar to match the dimensions of the field, up to a - // maximum width that is equal to 90% of the field's width. - .css({ - 'max-width': (document.documentElement.clientWidth < 450) ? document.documentElement.clientWidth : 450, - // 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%' - }); + that.$el.position({ + my: edge + ' bottom', + + at: edge + '+' + (1 + horizontalPadding) + ' top', + of: of, + collision: 'flipfit', + using: refinePosition.bind(null, that), + within: that.$fence + }).css({ + 'max-width': document.documentElement.clientWidth < 450 ? document.documentElement.clientWidth : 450, + + 'min-width': document.documentElement.clientWidth < 240 ? document.documentElement.clientWidth : 240, + 'width': '100%' + }); } - // 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(positionToolbar); }, delay); }, - /** - * Set the model state to 'saving' when the save button is clicked. - * - * @param {jQuery.Event} event - * The click event. - */ - onClickSave: function (event) { + onClickSave: function onClickSave(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} event - * The click event. - */ - onClickCancel: function (event) { + onClickCancel: function onClickCancel(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} event - * The mouse event. - */ - onMouseenter: function (event) { + onMouseenter: function onMouseenter(event) { clearTimeout(this.timer); }, - /** - * Builds the entity toolbar HTML; attaches to DOM; sets starting position. - * - * @return {jQuery} - * The toolbar element. - */ - buildToolbarEl: function () { + buildToolbarEl: function buildToolbarEl() { var $toolbar = $(Drupal.theme('quickeditEntityToolbar', { id: 'quickedit-entity-toolbar' })); - $toolbar - .find('.quickedit-toolbar-entity') - // Append the "ops" toolgroup into the toolbar. - .prepend(Drupal.theme('quickeditToolgroup', { - classes: ['ops'], - buttons: [ - { - label: Drupal.t('Save'), - type: 'submit', - classes: 'action-save quickedit-button icon', - attributes: { - 'aria-hidden': true - } - }, - { - label: Drupal.t('Close'), - classes: 'action-cancel quickedit-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 - }); + $toolbar.find('.quickedit-toolbar-entity').prepend(Drupal.theme('quickeditToolgroup', { + classes: ['ops'], + buttons: [{ + label: Drupal.t('Save'), + type: 'submit', + classes: 'action-save quickedit-button icon', + attributes: { + 'aria-hidden': true + } + }, { + label: Drupal.t('Close'), + classes: 'action-cancel quickedit-button icon icon-close icon-only' + }] + })); + + $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 () { + getToolbarRoot: function getToolbarRoot() { return this._fieldToolbarRoot; }, - /** - * Generates a state-dependent label for the entity toolbar. - */ - label: function () { - // The entity label. + label: function label() { var label = ''; var entityLabel = this.model.get('label'); - // Label of an active field, if it exists. var activeField = Drupal.quickedit.app.model.get('activeField'); var activeFieldLabel = activeField && activeField.get('metadata').label; - // Label of a highlighted field, if it exists. + var highlightedField = Drupal.quickedit.app.model.get('highlightedField'); var highlightedFieldLabel = highlightedField && highlightedField.get('metadata').label; - // The label is constructed in a priority order. + if (activeFieldLabel) { label = Drupal.theme('quickeditEntityToolbarLabel', { entityLabel: entityLabel, fieldLabel: activeFieldLabel }); - } - else if (highlightedFieldLabel) { + } else if (highlightedFieldLabel) { label = Drupal.theme('quickeditEntityToolbarLabel', { entityLabel: entityLabel, fieldLabel: highlightedFieldLabel }); - } - else { - // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437 + } else { label = Drupal.checkPlain(entityLabel); } - this.$el - .find('.quickedit-toolbar-label') - .html(label); + this.$el.find('.quickedit-toolbar-label').html(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) { + addClass: function addClass(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) { + removeClass: function removeClass(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) { + _find: function _find(toolgroup) { return this.$el.find('.quickedit-toolbar .quickedit-toolgroup.' + toolgroup); }, - /** - * Shows a toolgroup. - * - * @param {string} toolgroup - * A toolgroup name. - */ - show: function (toolgroup) { + show: function show(toolgroup) { this.$el.removeClass('quickedit-animate-invisible'); } }); - -})(jQuery, _, Backbone, Drupal, Drupal.debounce); +})(jQuery, _, Backbone, Drupal, Drupal.debounce); \ No newline at end of file diff --git a/core/modules/quickedit/js/views/FieldDecorationView.es6.js b/core/modules/quickedit/js/views/FieldDecorationView.es6.js new file mode 100644 index 000000000000..966e2b9fbc7e --- /dev/null +++ b/core/modules/quickedit/js/views/FieldDecorationView.es6.js @@ -0,0 +1,360 @@ +/** + * @file + * A Backbone View that decorates the in-place edited element. + */ + +(function ($, Backbone, Drupal) { + + 'use strict'; + + Drupal.quickedit.FieldDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldDecorationView# */{ + + /** + * @type {null} + */ + _widthAttributeIsEmpty: null, + + /** + * @type {object} + */ + events: { + 'mouseenter.quickedit': 'onMouseEnter', + 'mouseleave.quickedit': 'onMouseLeave', + 'click': 'onClick', + 'tabIn.quickedit': 'onMouseEnter', + 'tabOut.quickedit': 'onMouseLeave' + }, + + /** + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * An object with the following keys: + * @param {Drupal.quickedit.EditorView} options.editorView + * The editor object view. + */ + initialize: function (options) { + this.editorView = options.editorView; + + this.listenTo(this.model, 'change:state', this.stateChange); + this.listenTo(this.model, 'change:isChanged change:inTempStore', this.renderChanged); + }, + + /** + * @inheritdoc + */ + remove: function () { + // The el property is the field, which should not be removed. Remove the + // pointer to it, then call Backbone.View.prototype.remove(). + this.setElement(); + Backbone.View.prototype.remove.call(this); + }, + + /** + * Determines the actions to take given a change of state. + * + * @param {Drupal.quickedit.FieldModel} model + * The `FieldModel` model. + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.FieldModel.states}. + */ + stateChange: function (model, state) { + var from = model.previous('state'); + var to = state; + switch (to) { + case 'inactive': + this.undecorate(); + break; + + case 'candidate': + this.decorate(); + if (from !== 'inactive') { + this.stopHighlight(); + if (from !== 'highlighted') { + this.model.set('isChanged', false); + this.stopEdit(); + } + } + this._unpad(); + break; + + case 'highlighted': + this.startHighlight(); + break; + + case 'activating': + // NOTE: this state is not used by every editor! It's only used by + // those that need to interact with the server. + this.prepareEdit(); + break; + + case 'active': + if (from !== 'activating') { + this.prepareEdit(); + } + if (this.editorView.getQuickEditUISettings().padding) { + this._pad(); + } + break; + + case 'changed': + this.model.set('isChanged', true); + break; + + case 'saving': + break; + + case 'saved': + break; + + case 'invalid': + break; + } + }, + + /** + * 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 + * PrivateTempStore). + */ + renderChanged: function () { + this.$el.toggleClass('quickedit-changed', this.model.get('isChanged') || this.model.get('inTempStore')); + }, + + /** + * Starts hover; transitions to 'highlight' state. + * + * @param {jQuery.Event} event + * The mouse event. + */ + onMouseEnter: function (event) { + var that = this; + that.model.set('state', 'highlighted'); + event.stopPropagation(); + }, + + /** + * Stops hover; transitions to 'candidate' state. + * + * @param {jQuery.Event} event + * The mouse event. + */ + onMouseLeave: function (event) { + var that = this; + that.model.set('state', 'candidate', {reason: 'mouseleave'}); + event.stopPropagation(); + }, + + /** + * Transition to 'activating' stage. + * + * @param {jQuery.Event} event + * The click event. + */ + onClick: function (event) { + this.model.set('state', 'activating'); + event.preventDefault(); + event.stopPropagation(); + }, + + /** + * Adds classes used to indicate an elements editable state. + */ + decorate: function () { + this.$el.addClass('quickedit-candidate quickedit-editable'); + }, + + /** + * Removes classes used to indicate an elements editable state. + */ + undecorate: function () { + this.$el.removeClass('quickedit-candidate quickedit-editable quickedit-highlighted quickedit-editing'); + }, + + /** + * Adds that class that indicates that an element is highlighted. + */ + startHighlight: function () { + // Animations. + var that = this; + // Use a timeout to grab the next available animation frame. + that.$el.addClass('quickedit-highlighted'); + }, + + /** + * Removes the class that indicates that an element is highlighted. + */ + stopHighlight: function () { + this.$el.removeClass('quickedit-highlighted'); + }, + + /** + * Removes the class that indicates that an element as editable. + */ + prepareEdit: function () { + this.$el.addClass('quickedit-editing'); + + // Allow the field to be styled differently while editing in a pop-up + // in-place editor. + if (this.editorView.getQuickEditUISettings().popup) { + this.$el.addClass('quickedit-editor-is-popup'); + } + }, + + /** + * Removes the class that indicates that an element is being edited. + * + * Reapplies the class that indicates that a candidate editable element is + * again available to be edited. + */ + stopEdit: function () { + this.$el.removeClass('quickedit-highlighted quickedit-editing'); + + // Done editing in a pop-up in-place editor; remove the class. + if (this.editorView.getQuickEditUISettings().popup) { + this.$el.removeClass('quickedit-editor-is-popup'); + } + + // Make the other editors show up again. + $('.quickedit-candidate').addClass('quickedit-editable'); + }, + + /** + * Adds padding around the editable element to make it pop visually. + */ + _pad: function () { + // Early return if the element has already been padded. + if (this.$el.data('quickedit-padded')) { + return; + } + var self = this; + + // Add 5px padding for readability. This means we'll freeze the current + // width and *then* add 5px padding, hence ensuring the padding is added + // "on the outside". + // 1) Freeze the width (if it's not already set); don't use animations. + if (this.$el[0].style.width === '') { + this._widthAttributeIsEmpty = true; + this.$el + .addClass('quickedit-animate-disable-width') + .css('width', this.$el.width()); + } + + // 2) Add padding; use animations. + var posProp = this._getPositionProperties(this.$el); + setTimeout(function () { + // Re-enable width animations (padding changes affect width too!). + self.$el.removeClass('quickedit-animate-disable-width'); + + // Pad the editable. + self.$el + .css({ + 'position': 'relative', + 'top': posProp.top - 5 + 'px', + 'left': posProp.left - 5 + 'px', + 'padding-top': posProp['padding-top'] + 5 + 'px', + 'padding-left': posProp['padding-left'] + 5 + 'px', + 'padding-right': posProp['padding-right'] + 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] + 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] - 10 + 'px' + }) + .data('quickedit-padded', true); + }, 0); + }, + + /** + * Removes the padding around the element being edited when editing ceases. + */ + _unpad: function () { + // Early return if the element has not been padded. + if (!this.$el.data('quickedit-padded')) { + return; + } + var self = this; + + // 1) Set the empty width again. + if (this._widthAttributeIsEmpty) { + this.$el + .addClass('quickedit-animate-disable-width') + .css('width', ''); + } + + // 2) Remove padding; use animations (these will run simultaneously with) + // the fading out of the toolbar as its gets removed). + var posProp = this._getPositionProperties(this.$el); + setTimeout(function () { + // Re-enable width animations (padding changes affect width too!). + self.$el.removeClass('quickedit-animate-disable-width'); + + // Unpad the editable. + self.$el + .css({ + 'position': 'relative', + 'top': posProp.top + 5 + 'px', + 'left': posProp.left + 5 + 'px', + 'padding-top': posProp['padding-top'] - 5 + 'px', + 'padding-left': posProp['padding-left'] - 5 + 'px', + 'padding-right': posProp['padding-right'] - 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] - 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] + 10 + 'px' + }); + }, 0); + // Remove the marker that indicates that this field has padding. This is + // done outside the timed out function above so that we don't get numerous + // queued functions that will remove padding before the data marker has + // been removed. + this.$el.removeData('quickedit-padded'); + }, + + /** + * Gets the top and left properties of an element. + * + * Convert extraneous values and information into numbers ready for + * subtraction. + * + * @param {jQuery} $e + * The element to get position properties from. + * + * @return {object} + * An object containing css values for the needed properties. + */ + _getPositionProperties: function ($e) { + var p; + var r = {}; + var props = [ + 'top', 'left', 'bottom', 'right', + 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', + 'margin-bottom' + ]; + + var propCount = props.length; + for (var i = 0; i < propCount; i++) { + p = props[i]; + r[p] = parseInt(this._replaceBlankPosition($e.css(p)), 10); + } + return r; + }, + + /** + * Replaces blank or 'auto' CSS `position: <value>` values with "0px". + * + * @param {string} [pos] + * The value for a CSS position declaration. + * + * @return {string} + * A CSS value that is valid for `position`. + */ + _replaceBlankPosition: function (pos) { + if (pos === 'auto' || !pos) { + pos = '0px'; + } + return pos; + } + + }); + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/quickedit/js/views/FieldDecorationView.js b/core/modules/quickedit/js/views/FieldDecorationView.js index 966e2b9fbc7e..cfe175d5cca7 100644 --- a/core/modules/quickedit/js/views/FieldDecorationView.js +++ b/core/modules/quickedit/js/views/FieldDecorationView.js @@ -1,22 +1,18 @@ /** - * @file - * A Backbone View that decorates the in-place edited element. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/views/FieldDecorationView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Backbone, Drupal) { 'use strict'; - Drupal.quickedit.FieldDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldDecorationView# */{ - - /** - * @type {null} - */ + Drupal.quickedit.FieldDecorationView = Backbone.View.extend({ _widthAttributeIsEmpty: null, - /** - * @type {object} - */ events: { 'mouseenter.quickedit': 'onMouseEnter', 'mouseleave.quickedit': 'onMouseLeave', @@ -25,43 +21,19 @@ 'tabOut.quickedit': 'onMouseLeave' }, - /** - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * An object with the following keys: - * @param {Drupal.quickedit.EditorView} options.editorView - * The editor object view. - */ - initialize: function (options) { + initialize: function initialize(options) { this.editorView = options.editorView; this.listenTo(this.model, 'change:state', this.stateChange); this.listenTo(this.model, 'change:isChanged change:inTempStore', this.renderChanged); }, - /** - * @inheritdoc - */ - remove: function () { - // The el property is the field, which should not be removed. Remove the - // pointer to it, then call Backbone.View.prototype.remove(). + remove: function remove() { this.setElement(); Backbone.View.prototype.remove.call(this); }, - /** - * Determines the actions to take given a change of state. - * - * @param {Drupal.quickedit.FieldModel} model - * The `FieldModel` model. - * @param {string} state - * The state of the associated field. One of - * {@link Drupal.quickedit.FieldModel.states}. - */ - stateChange: function (model, state) { + stateChange: function stateChange(model, state) { var from = model.previous('state'); var to = state; switch (to) { @@ -86,8 +58,6 @@ break; case 'activating': - // NOTE: this state is not used by every editor! It's only used by - // those that need to interact with the server. this.prepareEdit(); break; @@ -115,221 +85,125 @@ } }, - /** - * 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 - * PrivateTempStore). - */ - renderChanged: function () { + renderChanged: function renderChanged() { this.$el.toggleClass('quickedit-changed', this.model.get('isChanged') || this.model.get('inTempStore')); }, - /** - * Starts hover; transitions to 'highlight' state. - * - * @param {jQuery.Event} event - * The mouse event. - */ - onMouseEnter: function (event) { + onMouseEnter: function onMouseEnter(event) { var that = this; that.model.set('state', 'highlighted'); event.stopPropagation(); }, - /** - * Stops hover; transitions to 'candidate' state. - * - * @param {jQuery.Event} event - * The mouse event. - */ - onMouseLeave: function (event) { + onMouseLeave: function onMouseLeave(event) { var that = this; - that.model.set('state', 'candidate', {reason: 'mouseleave'}); + that.model.set('state', 'candidate', { reason: 'mouseleave' }); event.stopPropagation(); }, - /** - * Transition to 'activating' stage. - * - * @param {jQuery.Event} event - * The click event. - */ - onClick: function (event) { + onClick: function onClick(event) { this.model.set('state', 'activating'); event.preventDefault(); event.stopPropagation(); }, - /** - * Adds classes used to indicate an elements editable state. - */ - decorate: function () { + decorate: function decorate() { this.$el.addClass('quickedit-candidate quickedit-editable'); }, - /** - * Removes classes used to indicate an elements editable state. - */ - undecorate: function () { + undecorate: function undecorate() { this.$el.removeClass('quickedit-candidate quickedit-editable quickedit-highlighted quickedit-editing'); }, - /** - * Adds that class that indicates that an element is highlighted. - */ - startHighlight: function () { - // Animations. + startHighlight: function startHighlight() { var that = this; - // Use a timeout to grab the next available animation frame. + that.$el.addClass('quickedit-highlighted'); }, - /** - * Removes the class that indicates that an element is highlighted. - */ - stopHighlight: function () { + stopHighlight: function stopHighlight() { this.$el.removeClass('quickedit-highlighted'); }, - /** - * Removes the class that indicates that an element as editable. - */ - prepareEdit: function () { + prepareEdit: function prepareEdit() { this.$el.addClass('quickedit-editing'); - // Allow the field to be styled differently while editing in a pop-up - // in-place editor. if (this.editorView.getQuickEditUISettings().popup) { this.$el.addClass('quickedit-editor-is-popup'); } }, - /** - * Removes the class that indicates that an element is being edited. - * - * Reapplies the class that indicates that a candidate editable element is - * again available to be edited. - */ - stopEdit: function () { + stopEdit: function stopEdit() { this.$el.removeClass('quickedit-highlighted quickedit-editing'); - // Done editing in a pop-up in-place editor; remove the class. if (this.editorView.getQuickEditUISettings().popup) { this.$el.removeClass('quickedit-editor-is-popup'); } - // Make the other editors show up again. $('.quickedit-candidate').addClass('quickedit-editable'); }, - /** - * Adds padding around the editable element to make it pop visually. - */ - _pad: function () { - // Early return if the element has already been padded. + _pad: function _pad() { if (this.$el.data('quickedit-padded')) { return; } var self = this; - // Add 5px padding for readability. This means we'll freeze the current - // width and *then* add 5px padding, hence ensuring the padding is added - // "on the outside". - // 1) Freeze the width (if it's not already set); don't use animations. if (this.$el[0].style.width === '') { this._widthAttributeIsEmpty = true; - this.$el - .addClass('quickedit-animate-disable-width') - .css('width', this.$el.width()); + this.$el.addClass('quickedit-animate-disable-width').css('width', this.$el.width()); } - // 2) Add padding; use animations. var posProp = this._getPositionProperties(this.$el); setTimeout(function () { - // Re-enable width animations (padding changes affect width too!). self.$el.removeClass('quickedit-animate-disable-width'); - // Pad the editable. - self.$el - .css({ - 'position': 'relative', - 'top': posProp.top - 5 + 'px', - 'left': posProp.left - 5 + 'px', - 'padding-top': posProp['padding-top'] + 5 + 'px', - 'padding-left': posProp['padding-left'] + 5 + 'px', - 'padding-right': posProp['padding-right'] + 5 + 'px', - 'padding-bottom': posProp['padding-bottom'] + 5 + 'px', - 'margin-bottom': posProp['margin-bottom'] - 10 + 'px' - }) - .data('quickedit-padded', true); + self.$el.css({ + 'position': 'relative', + 'top': posProp.top - 5 + 'px', + 'left': posProp.left - 5 + 'px', + 'padding-top': posProp['padding-top'] + 5 + 'px', + 'padding-left': posProp['padding-left'] + 5 + 'px', + 'padding-right': posProp['padding-right'] + 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] + 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] - 10 + 'px' + }).data('quickedit-padded', true); }, 0); }, - /** - * Removes the padding around the element being edited when editing ceases. - */ - _unpad: function () { - // Early return if the element has not been padded. + _unpad: function _unpad() { if (!this.$el.data('quickedit-padded')) { return; } var self = this; - // 1) Set the empty width again. if (this._widthAttributeIsEmpty) { - this.$el - .addClass('quickedit-animate-disable-width') - .css('width', ''); + this.$el.addClass('quickedit-animate-disable-width').css('width', ''); } - // 2) Remove padding; use animations (these will run simultaneously with) - // the fading out of the toolbar as its gets removed). var posProp = this._getPositionProperties(this.$el); setTimeout(function () { - // Re-enable width animations (padding changes affect width too!). self.$el.removeClass('quickedit-animate-disable-width'); - // Unpad the editable. - self.$el - .css({ - 'position': 'relative', - 'top': posProp.top + 5 + 'px', - 'left': posProp.left + 5 + 'px', - 'padding-top': posProp['padding-top'] - 5 + 'px', - 'padding-left': posProp['padding-left'] - 5 + 'px', - 'padding-right': posProp['padding-right'] - 5 + 'px', - 'padding-bottom': posProp['padding-bottom'] - 5 + 'px', - 'margin-bottom': posProp['margin-bottom'] + 10 + 'px' - }); + self.$el.css({ + 'position': 'relative', + 'top': posProp.top + 5 + 'px', + 'left': posProp.left + 5 + 'px', + 'padding-top': posProp['padding-top'] - 5 + 'px', + 'padding-left': posProp['padding-left'] - 5 + 'px', + 'padding-right': posProp['padding-right'] - 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] - 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] + 10 + 'px' + }); }, 0); - // Remove the marker that indicates that this field has padding. This is - // done outside the timed out function above so that we don't get numerous - // queued functions that will remove padding before the data marker has - // been removed. + this.$el.removeData('quickedit-padded'); }, - /** - * Gets the top and left properties of an element. - * - * Convert extraneous values and information into numbers ready for - * subtraction. - * - * @param {jQuery} $e - * The element to get position properties from. - * - * @return {object} - * An object containing css values for the needed properties. - */ - _getPositionProperties: function ($e) { + _getPositionProperties: function _getPositionProperties($e) { var p; var r = {}; - var props = [ - 'top', 'left', 'bottom', 'right', - 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', - 'margin-bottom' - ]; + var props = ['top', 'left', 'bottom', 'right', 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', 'margin-bottom']; var propCount = props.length; for (var i = 0; i < propCount; i++) { @@ -339,16 +213,7 @@ return r; }, - /** - * Replaces blank or 'auto' CSS `position: <value>` values with "0px". - * - * @param {string} [pos] - * The value for a CSS position declaration. - * - * @return {string} - * A CSS value that is valid for `position`. - */ - _replaceBlankPosition: function (pos) { + _replaceBlankPosition: function _replaceBlankPosition(pos) { if (pos === 'auto' || !pos) { pos = '0px'; } @@ -356,5 +221,4 @@ } }); - -})(jQuery, Backbone, Drupal); +})(jQuery, Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/quickedit/js/views/FieldToolbarView.es6.js b/core/modules/quickedit/js/views/FieldToolbarView.es6.js new file mode 100644 index 000000000000..dbd531f44132 --- /dev/null +++ b/core/modules/quickedit/js/views/FieldToolbarView.es6.js @@ -0,0 +1,227 @@ +/** + * @file + * A Backbone View that provides an interactive toolbar (1 per in-place editor). + */ + +(function ($, _, Backbone, Drupal) { + + 'use strict'; + + Drupal.quickedit.FieldToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldToolbarView# */{ + + /** + * The edited element, as indicated by EditorView.getEditedElement. + * + * @type {jQuery} + */ + $editedElement: null, + + /** + * A reference to the in-place editor. + * + * @type {Drupal.quickedit.EditorView} + */ + editorView: null, + + /** + * @type {string} + */ + _id: null, + + /** + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * Options object to construct the field toolbar. + * @param {jQuery} options.$editedElement + * The element being edited. + * @param {Drupal.quickedit.EditorView} options.editorView + * The EditorView the toolbar belongs to. + */ + initialize: function (options) { + this.$editedElement = options.$editedElement; + this.editorView = options.editorView; + + /** + * @type {jQuery} + */ + this.$root = this.$el; + + // Generate a DOM-compatible ID for the form container DOM element. + this._id = 'quickedit-toolbar-for-' + this.model.id.replace(/[\/\[\]]/g, '_'); + + this.listenTo(this.model, 'change:state', this.stateChange); + }, + + /** + * @inheritdoc + * + * @return {Drupal.quickedit.FieldToolbarView} + * The current FieldToolbarView. + */ + render: function () { + // Render toolbar and set it as the view's element. + this.setElement($(Drupal.theme('quickeditFieldToolbar', { + id: this._id + }))); + + // Attach to the field toolbar $root element in the entity toolbar. + this.$el.prependTo(this.$root); + + return this; + }, + + /** + * Determines the actions to take given a change of state. + * + * @param {Drupal.quickedit.FieldModel} model + * The quickedit FieldModel + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.FieldModel.states}. + */ + stateChange: function (model, state) { + var from = model.previous('state'); + var to = state; + switch (to) { + case 'inactive': + break; + + case 'candidate': + // 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': + break; + + case 'activating': + this.render(); + + if (this.editorView.getQuickEditUISettings().fullWidthToolbar) { + this.$el.addClass('quickedit-toolbar-fullwidth'); + } + + if (this.editorView.getQuickEditUISettings().unifiedToolbar) { + this.insertWYSIWYGToolGroups(); + } + break; + + case 'active': + break; + + case 'changed': + break; + + case 'saving': + break; + + case 'saved': + break; + + case 'invalid': + break; + } + }, + + /** + * Insert WYSIWYG markup into the associated toolbar. + */ + insertWYSIWYGToolGroups: function () { + this.$el + .append(Drupal.theme('quickeditToolgroup', { + id: this.getFloatedWysiwygToolgroupId(), + classes: ['wysiwyg-floated', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'], + buttons: [] + })) + .append(Drupal.theme('quickeditToolgroup', { + id: this.getMainWysiwygToolgroupId(), + classes: ['wysiwyg-main', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'], + buttons: [] + })); + + // Animate the toolgroups into visibility. + this.show('wysiwyg-floated'); + this.show('wysiwyg-main'); + }, + + /** + * Retrieves the ID for this toolbar's container. + * + * Only used to make sane hovering behavior possible. + * + * @return {string} + * A string that can be used as the ID for this toolbar's container. + */ + getId: function () { + return 'quickedit-toolbar-for-' + this._id; + }, + + /** + * Retrieves the ID for this toolbar's floating WYSIWYG toolgroup. + * + * Used to provide an abstraction for any WYSIWYG editor to plug in. + * + * @return {string} + * A string that can be used as the ID. + */ + getFloatedWysiwygToolgroupId: function () { + return 'quickedit-wysiwyg-floated-toolgroup-for-' + this._id; + }, + + /** + * Retrieves the ID for this toolbar's main WYSIWYG toolgroup. + * + * Used to provide an abstraction for any WYSIWYG editor to plug in. + * + * @return {string} + * A string that can be used as the ID. + */ + getMainWysiwygToolgroupId: function () { + return 'quickedit-wysiwyg-main-toolgroup-for-' + this._id; + }, + + /** + * Finds a toolgroup. + * + * @param {string} toolgroup + * A toolgroup name. + * + * @return {jQuery} + * The toolgroup element. + */ + _find: function (toolgroup) { + return this.$el.find('.quickedit-toolgroup.' + toolgroup); + }, + + /** + * Shows a toolgroup. + * + * @param {string} toolgroup + * A toolgroup name. + */ + 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.quickedit.util.constants.transitionEnd, function (event) { + $group.off(Drupal.quickedit.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('quickedit-animate-invisible'); + }, 0); + } + + }); + +})(jQuery, _, Backbone, Drupal); diff --git a/core/modules/quickedit/js/views/FieldToolbarView.js b/core/modules/quickedit/js/views/FieldToolbarView.js index dbd531f44132..488730e15243 100644 --- a/core/modules/quickedit/js/views/FieldToolbarView.js +++ b/core/modules/quickedit/js/views/FieldToolbarView.js @@ -1,88 +1,44 @@ /** - * @file - * A Backbone View that provides an interactive toolbar (1 per in-place editor). - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/quickedit/js/views/FieldToolbarView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, _, Backbone, Drupal) { 'use strict'; - Drupal.quickedit.FieldToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldToolbarView# */{ - - /** - * The edited element, as indicated by EditorView.getEditedElement. - * - * @type {jQuery} - */ + Drupal.quickedit.FieldToolbarView = Backbone.View.extend({ $editedElement: null, - /** - * A reference to the in-place editor. - * - * @type {Drupal.quickedit.EditorView} - */ editorView: null, - /** - * @type {string} - */ _id: null, - /** - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options object to construct the field toolbar. - * @param {jQuery} options.$editedElement - * The element being edited. - * @param {Drupal.quickedit.EditorView} options.editorView - * The EditorView the toolbar belongs to. - */ - initialize: function (options) { + initialize: function initialize(options) { this.$editedElement = options.$editedElement; this.editorView = options.editorView; - /** - * @type {jQuery} - */ this.$root = this.$el; - // Generate a DOM-compatible ID for the form container DOM element. this._id = 'quickedit-toolbar-for-' + this.model.id.replace(/[\/\[\]]/g, '_'); this.listenTo(this.model, 'change:state', this.stateChange); }, - /** - * @inheritdoc - * - * @return {Drupal.quickedit.FieldToolbarView} - * The current FieldToolbarView. - */ - render: function () { - // Render toolbar and set it as the view's element. + render: function render() { this.setElement($(Drupal.theme('quickeditFieldToolbar', { id: this._id }))); - // Attach to the field toolbar $root element in the entity toolbar. this.$el.prependTo(this.$root); return this; }, - /** - * Determines the actions to take given a change of state. - * - * @param {Drupal.quickedit.FieldModel} model - * The quickedit FieldModel - * @param {string} state - * The state of the associated field. One of - * {@link Drupal.quickedit.FieldModel.states}. - */ - stateChange: function (model, state) { + stateChange: function stateChange(model, state) { var from = model.previous('state'); var to = state; switch (to) { @@ -90,9 +46,6 @@ break; case 'candidate': - // 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(); @@ -131,97 +84,48 @@ } }, - /** - * Insert WYSIWYG markup into the associated toolbar. - */ - insertWYSIWYGToolGroups: function () { - this.$el - .append(Drupal.theme('quickeditToolgroup', { - id: this.getFloatedWysiwygToolgroupId(), - classes: ['wysiwyg-floated', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'], - buttons: [] - })) - .append(Drupal.theme('quickeditToolgroup', { - id: this.getMainWysiwygToolgroupId(), - classes: ['wysiwyg-main', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'], - buttons: [] - })); - - // Animate the toolgroups into visibility. + insertWYSIWYGToolGroups: function insertWYSIWYGToolGroups() { + this.$el.append(Drupal.theme('quickeditToolgroup', { + id: this.getFloatedWysiwygToolgroupId(), + classes: ['wysiwyg-floated', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'], + buttons: [] + })).append(Drupal.theme('quickeditToolgroup', { + id: this.getMainWysiwygToolgroupId(), + classes: ['wysiwyg-main', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'], + buttons: [] + })); + this.show('wysiwyg-floated'); this.show('wysiwyg-main'); }, - /** - * Retrieves the ID for this toolbar's container. - * - * Only used to make sane hovering behavior possible. - * - * @return {string} - * A string that can be used as the ID for this toolbar's container. - */ - getId: function () { + getId: function getId() { return 'quickedit-toolbar-for-' + this._id; }, - /** - * Retrieves the ID for this toolbar's floating WYSIWYG toolgroup. - * - * Used to provide an abstraction for any WYSIWYG editor to plug in. - * - * @return {string} - * A string that can be used as the ID. - */ - getFloatedWysiwygToolgroupId: function () { + getFloatedWysiwygToolgroupId: function getFloatedWysiwygToolgroupId() { return 'quickedit-wysiwyg-floated-toolgroup-for-' + this._id; }, - /** - * Retrieves the ID for this toolbar's main WYSIWYG toolgroup. - * - * Used to provide an abstraction for any WYSIWYG editor to plug in. - * - * @return {string} - * A string that can be used as the ID. - */ - getMainWysiwygToolgroupId: function () { + getMainWysiwygToolgroupId: function getMainWysiwygToolgroupId() { return 'quickedit-wysiwyg-main-toolgroup-for-' + this._id; }, - /** - * Finds a toolgroup. - * - * @param {string} toolgroup - * A toolgroup name. - * - * @return {jQuery} - * The toolgroup element. - */ - _find: function (toolgroup) { + _find: function _find(toolgroup) { return this.$el.find('.quickedit-toolgroup.' + toolgroup); }, - /** - * Shows a toolgroup. - * - * @param {string} toolgroup - * A toolgroup name. - */ - show: function (toolgroup) { + show: function show(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.quickedit.util.constants.transitionEnd, function (event) { $group.off(Drupal.quickedit.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('quickedit-animate-invisible'); }, 0); } }); - -})(jQuery, _, Backbone, Drupal); +})(jQuery, _, Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/responsive_image/js/responsive_image.ajax.es6.js b/core/modules/responsive_image/js/responsive_image.ajax.es6.js new file mode 100644 index 000000000000..52093193ec8a --- /dev/null +++ b/core/modules/responsive_image/js/responsive_image.ajax.es6.js @@ -0,0 +1,16 @@ +(function (Drupal) { + + 'use strict'; + + /** + * Call picturefill so newly added responsive images are processed. + */ + Drupal.behaviors.responsiveImageAJAX = { + attach: function () { + if (window.picturefill) { + window.picturefill(); + } + } + }; + +})(Drupal); diff --git a/core/modules/responsive_image/js/responsive_image.ajax.js b/core/modules/responsive_image/js/responsive_image.ajax.js index 52093193ec8a..d376e09b75d9 100644 --- a/core/modules/responsive_image/js/responsive_image.ajax.js +++ b/core/modules/responsive_image/js/responsive_image.ajax.js @@ -1,16 +1,20 @@ +/** +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/responsive_image/js/responsive_image.ajax.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + (function (Drupal) { 'use strict'; - /** - * Call picturefill so newly added responsive images are processed. - */ Drupal.behaviors.responsiveImageAJAX = { - attach: function () { + attach: function attach() { if (window.picturefill) { window.picturefill(); } } }; - -})(Drupal); +})(Drupal); \ No newline at end of file diff --git a/core/modules/simpletest/simpletest.es6.js b/core/modules/simpletest/simpletest.es6.js new file mode 100644 index 000000000000..ba54cbf74b35 --- /dev/null +++ b/core/modules/simpletest/simpletest.es6.js @@ -0,0 +1,130 @@ +/** + * @file + * Simpletest behaviors. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Collapses table rows followed by group rows on the test listing page. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attach collapse behavior on the test listing page. + */ + Drupal.behaviors.simpleTestGroupCollapse = { + attach: function (context) { + $(context).find('.simpletest-group').once('simpletest-group-collapse').each(function () { + var $group = $(this); + var $image = $group.find('.simpletest-image'); + $image + .html(drupalSettings.simpleTest.images[0]) + .on('click', function () { + var $tests = $group.nextUntil('.simpletest-group'); + var expand = !$group.hasClass('expanded'); + $group.toggleClass('expanded', expand); + $tests.toggleClass('js-hide', !expand); + $image.html(drupalSettings.simpleTest.images[+expand]); + }); + }); + } + }; + + /** + * Toggles test checkboxes to match the group checkbox. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for selecting all tests in a group. + */ + Drupal.behaviors.simpleTestSelectAll = { + attach: function (context) { + $(context).find('.simpletest-group').once('simpletest-group-select-all').each(function () { + var $group = $(this); + var $cell = $group.find('.simpletest-group-select-all'); + var $groupCheckbox = $('<input type="checkbox" id="' + $cell.attr('id') + '-group-select-all" class="form-checkbox" />'); + var $testCheckboxes = $group.nextUntil('.simpletest-group').find('input[type=checkbox]'); + $cell.append($groupCheckbox); + + // Toggle the test checkboxes when the group checkbox is toggled. + $groupCheckbox.on('change', function () { + var checked = $(this).prop('checked'); + $testCheckboxes.prop('checked', checked); + }); + + // Update the group checkbox when a test checkbox is toggled. + function updateGroupCheckbox() { + var allChecked = true; + $testCheckboxes.each(function () { + if (!$(this).prop('checked')) { + allChecked = false; + return false; + } + }); + $groupCheckbox.prop('checked', allChecked); + } + + $testCheckboxes.on('change', updateGroupCheckbox); + }); + } + }; + + /** + * Filters the test list table by a text input search string. + * + * Text search input: input.table-filter-text + * Target table: input.table-filter-text[data-table] + * Source text: .table-filter-text-source + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the filter behavior to the text input element. + */ + Drupal.behaviors.simpletestTableFilterByText = { + attach: function (context) { + var $input = $('input.table-filter-text').once('table-filter-text'); + var $table = $($input.attr('data-table')); + var $rows; + var searched = false; + + function filterTestList(e) { + var query = $(e.target).val().toLowerCase(); + + function showTestRow(index, row) { + var $row = $(row); + var $sources = $row.find('.table-filter-text-source'); + var textMatch = $sources.text().toLowerCase().indexOf(query) !== -1; + $row.closest('tr').toggle(textMatch); + } + + // Filter if the length of the query is at least 3 characters. + if (query.length >= 3) { + // Indicate that a search has been performed, and hide the + // "select all" checkbox. + searched = true; + $('#simpletest-form-table thead th.select-all input').hide(); + + $rows.each(showTestRow); + } + // Restore to the original state if any searching has occurred. + else if (searched) { + searched = false; + $('#simpletest-form-table thead th.select-all input').show(); + // Restore all rows to their original display state. + $rows.css('display', ''); + } + } + + if ($table.length) { + $rows = $table.find('tbody tr'); + $input.trigger('focus').on('keyup', Drupal.debounce(filterTestList, 200)); + } + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/simpletest/simpletest.js b/core/modules/simpletest/simpletest.js index ba54cbf74b35..4bdcb1164fcd 100644 --- a/core/modules/simpletest/simpletest.js +++ b/core/modules/simpletest/simpletest.js @@ -1,48 +1,33 @@ /** - * @file - * Simpletest behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/simpletest/simpletest.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Collapses table rows followed by group rows on the test listing page. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attach collapse behavior on the test listing page. - */ Drupal.behaviors.simpleTestGroupCollapse = { - attach: function (context) { + attach: function attach(context) { $(context).find('.simpletest-group').once('simpletest-group-collapse').each(function () { var $group = $(this); var $image = $group.find('.simpletest-image'); - $image - .html(drupalSettings.simpleTest.images[0]) - .on('click', function () { - var $tests = $group.nextUntil('.simpletest-group'); - var expand = !$group.hasClass('expanded'); - $group.toggleClass('expanded', expand); - $tests.toggleClass('js-hide', !expand); - $image.html(drupalSettings.simpleTest.images[+expand]); - }); + $image.html(drupalSettings.simpleTest.images[0]).on('click', function () { + var $tests = $group.nextUntil('.simpletest-group'); + var expand = !$group.hasClass('expanded'); + $group.toggleClass('expanded', expand); + $tests.toggleClass('js-hide', !expand); + $image.html(drupalSettings.simpleTest.images[+expand]); + }); }); } }; - /** - * Toggles test checkboxes to match the group checkbox. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior for selecting all tests in a group. - */ Drupal.behaviors.simpleTestSelectAll = { - attach: function (context) { + attach: function attach(context) { $(context).find('.simpletest-group').once('simpletest-group-select-all').each(function () { var $group = $(this); var $cell = $group.find('.simpletest-group-select-all'); @@ -50,13 +35,11 @@ var $testCheckboxes = $group.nextUntil('.simpletest-group').find('input[type=checkbox]'); $cell.append($groupCheckbox); - // Toggle the test checkboxes when the group checkbox is toggled. $groupCheckbox.on('change', function () { var checked = $(this).prop('checked'); $testCheckboxes.prop('checked', checked); }); - // Update the group checkbox when a test checkbox is toggled. function updateGroupCheckbox() { var allChecked = true; $testCheckboxes.each(function () { @@ -73,20 +56,8 @@ } }; - /** - * Filters the test list table by a text input search string. - * - * Text search input: input.table-filter-text - * Target table: input.table-filter-text[data-table] - * Source text: .table-filter-text-source - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the filter behavior to the text input element. - */ Drupal.behaviors.simpletestTableFilterByText = { - attach: function (context) { + attach: function attach(context) { var $input = $('input.table-filter-text').once('table-filter-text'); var $table = $($input.attr('data-table')); var $rows; @@ -102,22 +73,17 @@ $row.closest('tr').toggle(textMatch); } - // Filter if the length of the query is at least 3 characters. if (query.length >= 3) { - // Indicate that a search has been performed, and hide the - // "select all" checkbox. searched = true; $('#simpletest-form-table thead th.select-all input').hide(); $rows.each(showTestRow); - } - // Restore to the original state if any searching has occurred. - else if (searched) { - searched = false; - $('#simpletest-form-table thead th.select-all input').show(); - // Restore all rows to their original display state. - $rows.css('display', ''); - } + } else if (searched) { + searched = false; + $('#simpletest-form-table thead th.select-all input').show(); + + $rows.css('display', ''); + } } if ($table.length) { @@ -126,5 +92,4 @@ } } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/statistics/statistics.es6.js b/core/modules/statistics/statistics.es6.js new file mode 100644 index 000000000000..572cd456ff97 --- /dev/null +++ b/core/modules/statistics/statistics.es6.js @@ -0,0 +1,18 @@ +/** + * @file + * Statistics functionality. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + $(document).ready(function () { + $.ajax({ + type: 'POST', + cache: false, + url: drupalSettings.statistics.url, + data: drupalSettings.statistics.data + }); + }); +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/statistics/statistics.js b/core/modules/statistics/statistics.js index 572cd456ff97..c781dd8ffa0d 100644 --- a/core/modules/statistics/statistics.js +++ b/core/modules/statistics/statistics.js @@ -1,7 +1,10 @@ /** - * @file - * Statistics functionality. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/statistics/statistics.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { @@ -15,4 +18,4 @@ data: drupalSettings.statistics.data }); }); -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/system/js/system.date.es6.js b/core/modules/system/js/system.date.es6.js new file mode 100644 index 000000000000..678614d98c59 --- /dev/null +++ b/core/modules/system/js/system.date.es6.js @@ -0,0 +1,57 @@ +/** + * @file + * Provides date format preview feature. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + var dateFormats = drupalSettings.dateFormats; + + /** + * Display the preview for date format entered. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attach behavior for previewing date formats on input elements. + */ + Drupal.behaviors.dateFormat = { + attach: function (context) { + var $context = $(context); + var $source = $context.find('[data-drupal-date-formatter="source"]').once('dateFormat'); + var $target = $context.find('[data-drupal-date-formatter="preview"]').once('dateFormat'); + var $preview = $target.find('em'); + + // All elements have to exist. + if (!$source.length || !$target.length) { + return; + } + + /** + * Event handler that replaces date characters with value. + * + * @param {jQuery.Event} e + * The jQuery event triggered. + */ + function dateFormatHandler(e) { + var baseValue = $(e.target).val() || ''; + var dateString = baseValue.replace(/\\?(.?)/gi, function (key, value) { + return dateFormats[key] ? dateFormats[key] : value; + }); + + $preview.html(dateString); + $target.toggleClass('js-hide', !dateString.length); + } + + /** + * On given event triggers the date character replacement. + */ + $source.on('keyup.dateFormat change.dateFormat input.dateFormat', dateFormatHandler) + // Initialize preview. + .trigger('keyup'); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/system/js/system.date.js b/core/modules/system/js/system.date.js index 678614d98c59..e61097698642 100644 --- a/core/modules/system/js/system.date.js +++ b/core/modules/system/js/system.date.js @@ -1,7 +1,10 @@ /** - * @file - * Provides date format preview feature. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/system/js/system.date.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { @@ -9,32 +12,17 @@ var dateFormats = drupalSettings.dateFormats; - /** - * Display the preview for date format entered. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attach behavior for previewing date formats on input elements. - */ Drupal.behaviors.dateFormat = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); var $source = $context.find('[data-drupal-date-formatter="source"]').once('dateFormat'); var $target = $context.find('[data-drupal-date-formatter="preview"]').once('dateFormat'); var $preview = $target.find('em'); - // All elements have to exist. if (!$source.length || !$target.length) { return; } - /** - * Event handler that replaces date characters with value. - * - * @param {jQuery.Event} e - * The jQuery event triggered. - */ function dateFormatHandler(e) { var baseValue = $(e.target).val() || ''; var dateString = baseValue.replace(/\\?(.?)/gi, function (key, value) { @@ -45,13 +33,7 @@ $target.toggleClass('js-hide', !dateString.length); } - /** - * On given event triggers the date character replacement. - */ - $source.on('keyup.dateFormat change.dateFormat input.dateFormat', dateFormatHandler) - // Initialize preview. - .trigger('keyup'); + $source.on('keyup.dateFormat change.dateFormat input.dateFormat', dateFormatHandler).trigger('keyup'); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/system/js/system.es6.js b/core/modules/system/js/system.es6.js new file mode 100644 index 000000000000..82f0de66871b --- /dev/null +++ b/core/modules/system/js/system.es6.js @@ -0,0 +1,81 @@ +/** + * @file + * System behaviors. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + // Cache IDs in an array for ease of use. + var ids = []; + + /** + * Attaches field copy behavior from input fields to other input fields. + * + * When a field is filled out, apply its value to other fields that will + * likely use the same value. In the installer this is used to populate the + * administrator email address with the same value as the site email address. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the field copy behavior to an input field. + */ + Drupal.behaviors.copyFieldValue = { + attach: function (context) { + // List of fields IDs on which to bind the event listener. + // Create an array of IDs to use with jQuery. + for (var sourceId in drupalSettings.copyFieldValue) { + if (drupalSettings.copyFieldValue.hasOwnProperty(sourceId)) { + ids.push(sourceId); + } + } + if (ids.length) { + // Listen to value:copy events on all dependent fields. + // We have to use body and not document because of the way jQuery events + // bubble up the DOM tree. + $('body').once('copy-field-values').on('value:copy', this.valueTargetCopyHandler); + // Listen on all source elements. + $('#' + ids.join(', #')).once('copy-field-values').on('blur', this.valueSourceBlurHandler); + } + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload' && ids.length) { + $('body').removeOnce('copy-field-values').off('value:copy'); + $('#' + ids.join(', #')).removeOnce('copy-field-values').off('blur'); + } + }, + + /** + * Event handler that fill the target element with the specified value. + * + * @param {jQuery.Event} e + * Event object. + * @param {string} value + * Custom value from jQuery trigger. + */ + valueTargetCopyHandler: function (e, value) { + var $target = $(e.target); + if ($target.val() === '') { + $target.val(value); + } + }, + + /** + * Handler for a Blur event on a source field. + * + * This event handler will trigger a 'value:copy' event on all dependent + * fields. + * + * @param {jQuery.Event} e + * The event triggered. + */ + valueSourceBlurHandler: function (e) { + var value = $(e.target).val(); + var targetIds = drupalSettings.copyFieldValue[e.target.id]; + $('#' + targetIds.join(', #')).trigger('value:copy', value); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/system/js/system.js b/core/modules/system/js/system.js index 82f0de66871b..56c29f433d79 100644 --- a/core/modules/system/js/system.js +++ b/core/modules/system/js/system.js @@ -1,81 +1,48 @@ /** - * @file - * System behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/system/js/system.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - // Cache IDs in an array for ease of use. var ids = []; - /** - * Attaches field copy behavior from input fields to other input fields. - * - * When a field is filled out, apply its value to other fields that will - * likely use the same value. In the installer this is used to populate the - * administrator email address with the same value as the site email address. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the field copy behavior to an input field. - */ Drupal.behaviors.copyFieldValue = { - attach: function (context) { - // List of fields IDs on which to bind the event listener. - // Create an array of IDs to use with jQuery. + attach: function attach(context) { for (var sourceId in drupalSettings.copyFieldValue) { if (drupalSettings.copyFieldValue.hasOwnProperty(sourceId)) { ids.push(sourceId); } } if (ids.length) { - // Listen to value:copy events on all dependent fields. - // We have to use body and not document because of the way jQuery events - // bubble up the DOM tree. $('body').once('copy-field-values').on('value:copy', this.valueTargetCopyHandler); - // Listen on all source elements. + $('#' + ids.join(', #')).once('copy-field-values').on('blur', this.valueSourceBlurHandler); } }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload' && ids.length) { $('body').removeOnce('copy-field-values').off('value:copy'); $('#' + ids.join(', #')).removeOnce('copy-field-values').off('blur'); } }, - /** - * Event handler that fill the target element with the specified value. - * - * @param {jQuery.Event} e - * Event object. - * @param {string} value - * Custom value from jQuery trigger. - */ - valueTargetCopyHandler: function (e, value) { + valueTargetCopyHandler: function valueTargetCopyHandler(e, value) { var $target = $(e.target); if ($target.val() === '') { $target.val(value); } }, - /** - * Handler for a Blur event on a source field. - * - * This event handler will trigger a 'value:copy' event on all dependent - * fields. - * - * @param {jQuery.Event} e - * The event triggered. - */ - valueSourceBlurHandler: function (e) { + valueSourceBlurHandler: function valueSourceBlurHandler(e) { var value = $(e.target).val(); var targetIds = drupalSettings.copyFieldValue[e.target.id]; $('#' + targetIds.join(', #')).trigger('value:copy', value); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/system/js/system.modules.es6.js b/core/modules/system/js/system.modules.es6.js new file mode 100644 index 000000000000..d47ca7031e8f --- /dev/null +++ b/core/modules/system/js/system.modules.es6.js @@ -0,0 +1,103 @@ +/** + * @file + * Module page behaviors. + */ + +(function ($, Drupal, debounce) { + + 'use strict'; + + /** + * Filters the module list table by a text input search string. + * + * Additionally accounts for multiple tables being wrapped in "package" details + * elements. + * + * Text search input: input.table-filter-text + * Target table: input.table-filter-text[data-table] + * Source text: .table-filter-text-source, .module-name, .module-description + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.tableFilterByText = { + attach: function (context, settings) { + var $input = $('input.table-filter-text').once('table-filter-text'); + var $table = $($input.attr('data-table')); + var $rowsAndDetails; + var $rows; + var $details; + var searching = false; + + function hidePackageDetails(index, element) { + var $packDetails = $(element); + var $visibleRows = $packDetails.find('tbody tr:visible'); + $packDetails.toggle($visibleRows.length > 0); + } + + function filterModuleList(e) { + var query = $(e.target).val(); + // Case insensitive expression to find query at the beginning of a word. + var re = new RegExp('\\b' + query, 'i'); + + function showModuleRow(index, row) { + var $row = $(row); + var $sources = $row.find('.table-filter-text-source, .module-name, .module-description'); + var textMatch = $sources.text().search(re) !== -1; + $row.closest('tr').toggle(textMatch); + } + // Search over all rows and packages. + $rowsAndDetails.show(); + + // Filter if the length of the query is at least 2 characters. + if (query.length >= 2) { + searching = true; + $rows.each(showModuleRow); + + // Note that we first open all <details> to be able to use ':visible'. + // Mark the <details> elements that were closed before filtering, so + // they can be reclosed when filtering is removed. + $details.not('[open]').attr('data-drupal-system-state', 'forced-open'); + + // Hide the package <details> if they don't have any visible rows. + // Note that we first show() all <details> to be able to use ':visible'. + $details.attr('open', true).each(hidePackageDetails); + + Drupal.announce( + Drupal.t( + '!modules modules are available in the modified list.', + {'!modules': $rowsAndDetails.find('tbody tr:visible').length} + ) + ); + } + else if (searching) { + searching = false; + $rowsAndDetails.show(); + // Return <details> elements that had been closed before filtering + // to a closed state. + $details.filter('[data-drupal-system-state="forced-open"]') + .removeAttr('data-drupal-system-state') + .attr('open', false); + } + } + + function preventEnterKey(event) { + if (event.which === 13) { + event.preventDefault(); + event.stopPropagation(); + } + } + + if ($table.length) { + $rowsAndDetails = $table.find('tr, details'); + $rows = $table.find('tbody tr'); + $details = $rowsAndDetails.filter('.package-listing'); + + $input.on({ + keyup: debounce(filterModuleList, 200), + keydown: preventEnterKey + }); + } + } + }; + +}(jQuery, Drupal, Drupal.debounce)); diff --git a/core/modules/system/js/system.modules.js b/core/modules/system/js/system.modules.js index d47ca7031e8f..23dbcbeecced 100644 --- a/core/modules/system/js/system.modules.js +++ b/core/modules/system/js/system.modules.js @@ -1,26 +1,17 @@ /** - * @file - * Module page behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/system/js/system.modules.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, debounce) { 'use strict'; - /** - * Filters the module list table by a text input search string. - * - * Additionally accounts for multiple tables being wrapped in "package" details - * elements. - * - * Text search input: input.table-filter-text - * Target table: input.table-filter-text[data-table] - * Source text: .table-filter-text-source, .module-name, .module-description - * - * @type {Drupal~behavior} - */ Drupal.behaviors.tableFilterByText = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $input = $('input.table-filter-text').once('table-filter-text'); var $table = $($input.attr('data-table')); var $rowsAndDetails; @@ -36,7 +27,7 @@ function filterModuleList(e) { var query = $(e.target).val(); - // Case insensitive expression to find query at the beginning of a word. + var re = new RegExp('\\b' + query, 'i'); function showModuleRow(index, row) { @@ -45,38 +36,23 @@ var textMatch = $sources.text().search(re) !== -1; $row.closest('tr').toggle(textMatch); } - // Search over all rows and packages. + $rowsAndDetails.show(); - // Filter if the length of the query is at least 2 characters. if (query.length >= 2) { searching = true; $rows.each(showModuleRow); - // Note that we first open all <details> to be able to use ':visible'. - // Mark the <details> elements that were closed before filtering, so - // they can be reclosed when filtering is removed. $details.not('[open]').attr('data-drupal-system-state', 'forced-open'); - // Hide the package <details> if they don't have any visible rows. - // Note that we first show() all <details> to be able to use ':visible'. $details.attr('open', true).each(hidePackageDetails); - Drupal.announce( - Drupal.t( - '!modules modules are available in the modified list.', - {'!modules': $rowsAndDetails.find('tbody tr:visible').length} - ) - ); - } - else if (searching) { + Drupal.announce(Drupal.t('!modules modules are available in the modified list.', { '!modules': $rowsAndDetails.find('tbody tr:visible').length })); + } else if (searching) { searching = false; $rowsAndDetails.show(); - // Return <details> elements that had been closed before filtering - // to a closed state. - $details.filter('[data-drupal-system-state="forced-open"]') - .removeAttr('data-drupal-system-state') - .attr('open', false); + + $details.filter('[data-drupal-system-state="forced-open"]').removeAttr('data-drupal-system-state').attr('open', false); } } @@ -99,5 +75,4 @@ } } }; - -}(jQuery, Drupal, Drupal.debounce)); +})(jQuery, Drupal, Drupal.debounce); \ No newline at end of file diff --git a/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.es6.js b/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.es6.js new file mode 100644 index 000000000000..3fa82d98bc9d --- /dev/null +++ b/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.es6.js @@ -0,0 +1,22 @@ +/** + * @file + * Testing behavior for JSWebAssertTest. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Makes changes in the DOM to be able to test the completion of AJAX in assertWaitOnAjaxRequest. + */ + Drupal.behaviors.js_webassert_test_wait_for_ajax_request = { + attach: function (context) { + $('input[name="test_assert_wait_on_ajax_input"]').val('js_webassert_test'); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.js b/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.js index 3fa82d98bc9d..2a8ce93bbfc6 100644 --- a/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.js +++ b/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.js @@ -1,22 +1,18 @@ /** - * @file - * Testing behavior for JSWebAssertTest. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_ajax_request.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Makes changes in the DOM to be able to test the completion of AJAX in assertWaitOnAjaxRequest. - */ Drupal.behaviors.js_webassert_test_wait_for_ajax_request = { - attach: function (context) { + attach: function attach(context) { $('input[name="test_assert_wait_on_ajax_input"]').val('js_webassert_test'); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.es6.js b/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.es6.js new file mode 100644 index 000000000000..e11067b24cf8 --- /dev/null +++ b/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.es6.js @@ -0,0 +1,22 @@ +/** + * @file + * Testing behavior for JSWebAssertTest. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Makes changes in the DOM to be able to test the completion of AJAX in assertWaitOnAjaxRequest. + */ + Drupal.behaviors.js_webassert_test_wait_for_element = { + attach: function (context) { + $('#js_webassert_test_element_invisible').show(); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.js b/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.js index e11067b24cf8..61b7c787c01b 100644 --- a/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.js +++ b/core/modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.js @@ -1,22 +1,18 @@ /** - * @file - * Testing behavior for JSWebAssertTest. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/system/tests/modules/js_webassert_test/js/js_webassert_test.wait_for_element.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Makes changes in the DOM to be able to test the completion of AJAX in assertWaitOnAjaxRequest. - */ Drupal.behaviors.js_webassert_test_wait_for_element = { - attach: function (context) { + attach: function attach(context) { $('#js_webassert_test_element_invisible').show(); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.es6.js b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.es6.js new file mode 100644 index 000000000000..39bf0bb5724f --- /dev/null +++ b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.es6.js @@ -0,0 +1,6 @@ +/** + * @file + * This file is for testing asset file inclusion, no contents are necessary. + * + * @ignore + */ diff --git a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.js b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.js index 39bf0bb5724f..b106e35febfc 100644 --- a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.js +++ b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.js @@ -1,6 +1,7 @@ /** - * @file - * This file is for testing asset file inclusion, no contents are necessary. - * - * @ignore - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/system/tests/modules/twig_theme_test/twig_theme_test.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ \ No newline at end of file diff --git a/core/modules/system/tests/themes/test_theme/js/collapse.es6.js b/core/modules/system/tests/themes/test_theme/js/collapse.es6.js new file mode 100644 index 000000000000..4d66841e8164 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme/js/collapse.es6.js @@ -0,0 +1,4 @@ +/** + * @file + * Test JS asset file for test_theme.theme. + */ diff --git a/core/modules/system/tests/themes/test_theme/js/collapse.js b/core/modules/system/tests/themes/test_theme/js/collapse.js index 4d66841e8164..794f4a5a5b90 100644 --- a/core/modules/system/tests/themes/test_theme/js/collapse.js +++ b/core/modules/system/tests/themes/test_theme/js/collapse.js @@ -1,4 +1,7 @@ /** - * @file - * Test JS asset file for test_theme.theme. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/system/tests/themes/test_theme/js/collapse.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ \ No newline at end of file diff --git a/core/modules/taxonomy/taxonomy.es6.js b/core/modules/taxonomy/taxonomy.es6.js new file mode 100644 index 000000000000..99338d5a8ec2 --- /dev/null +++ b/core/modules/taxonomy/taxonomy.es6.js @@ -0,0 +1,56 @@ +/** + * @file + * Taxonomy behaviors. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Move a block in the blocks table from one region to another. + * + * This behavior is dependent on the tableDrag behavior, since it uses the + * objects initialized in that behavior to update the row. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the drag behavior to a applicable table element. + */ + Drupal.behaviors.termDrag = { + attach: function (context, settings) { + var backStep = settings.taxonomy.backStep; + var forwardStep = settings.taxonomy.forwardStep; + // Get the blocks tableDrag object. + var tableDrag = Drupal.tableDrag.taxonomy; + var $table = $('#taxonomy'); + var rows = $table.find('tr').length; + + // When a row is swapped, keep previous and next page classes set. + tableDrag.row.prototype.onSwap = function (swappedRow) { + $table.find('tr.taxonomy-term-preview').removeClass('taxonomy-term-preview'); + $table.find('tr.taxonomy-term-divider-top').removeClass('taxonomy-term-divider-top'); + $table.find('tr.taxonomy-term-divider-bottom').removeClass('taxonomy-term-divider-bottom'); + + var tableBody = $table[0].tBodies[0]; + if (backStep) { + for (var n = 0; n < backStep; n++) { + $(tableBody.rows[n]).addClass('taxonomy-term-preview'); + } + $(tableBody.rows[backStep - 1]).addClass('taxonomy-term-divider-top'); + $(tableBody.rows[backStep]).addClass('taxonomy-term-divider-bottom'); + } + + if (forwardStep) { + for (var k = rows - forwardStep - 1; k < rows - 1; k++) { + $(tableBody.rows[k]).addClass('taxonomy-term-preview'); + } + $(tableBody.rows[rows - forwardStep - 2]).addClass('taxonomy-term-divider-top'); + $(tableBody.rows[rows - forwardStep - 1]).addClass('taxonomy-term-divider-bottom'); + } + }; + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/taxonomy/taxonomy.js b/core/modules/taxonomy/taxonomy.js index 99338d5a8ec2..dfbdff318336 100644 --- a/core/modules/taxonomy/taxonomy.js +++ b/core/modules/taxonomy/taxonomy.js @@ -1,33 +1,24 @@ /** - * @file - * Taxonomy behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/taxonomy/taxonomy.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Move a block in the blocks table from one region to another. - * - * This behavior is dependent on the tableDrag behavior, since it uses the - * objects initialized in that behavior to update the row. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the drag behavior to a applicable table element. - */ Drupal.behaviors.termDrag = { - attach: function (context, settings) { + attach: function attach(context, settings) { var backStep = settings.taxonomy.backStep; var forwardStep = settings.taxonomy.forwardStep; - // Get the blocks tableDrag object. + var tableDrag = Drupal.tableDrag.taxonomy; var $table = $('#taxonomy'); var rows = $table.find('tr').length; - // When a row is swapped, keep previous and next page classes set. tableDrag.row.prototype.onSwap = function (swappedRow) { $table.find('tr.taxonomy-term-preview').removeClass('taxonomy-term-preview'); $table.find('tr.taxonomy-term-divider-top').removeClass('taxonomy-term-divider-top'); @@ -52,5 +43,4 @@ }; } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/text/text.es6.js b/core/modules/text/text.es6.js new file mode 100644 index 000000000000..ad89bd8ad161 --- /dev/null +++ b/core/modules/text/text.es6.js @@ -0,0 +1,61 @@ +/** + * @file + * Text behaviors. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Auto-hide summary textarea if empty and show hide and unhide links. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches auto-hide behavior on `text-summary` events. + */ + Drupal.behaviors.textSummary = { + attach: function (context, settings) { + $(context).find('.js-text-summary').once('text-summary').each(function () { + var $widget = $(this).closest('.js-text-format-wrapper'); + + var $summary = $widget.find('.js-text-summary-wrapper'); + var $summaryLabel = $summary.find('label').eq(0); + var $full = $widget.find('.js-text-full').closest('.js-form-item'); + var $fullLabel = $full.find('label').eq(0); + + // Create a placeholder label when the field cardinality is greater + // than 1. + if ($fullLabel.length === 0) { + $fullLabel = $('<label></label>').prependTo($full); + } + + // Set up the edit/hide summary link. + var $link = $('<span class="field-edit-link"> (<button type="button" class="link link-edit-summary">' + Drupal.t('Hide summary') + '</button>)</span>'); + var $button = $link.find('button'); + var toggleClick = true; + $link.on('click', function (e) { + if (toggleClick) { + $summary.hide(); + $button.html(Drupal.t('Edit summary')); + $link.appendTo($fullLabel); + } + else { + $summary.show(); + $button.html(Drupal.t('Hide summary')); + $link.appendTo($summaryLabel); + } + e.preventDefault(); + toggleClick = !toggleClick; + }).appendTo($summaryLabel); + + // If no summary is set, hide the summary field. + if ($widget.find('.js-text-summary').val() === '') { + $link.trigger('click'); + } + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/text/text.js b/core/modules/text/text.js index ad89bd8ad161..556954c20184 100644 --- a/core/modules/text/text.js +++ b/core/modules/text/text.js @@ -1,22 +1,17 @@ /** - * @file - * Text behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/text/text.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Auto-hide summary textarea if empty and show hide and unhide links. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches auto-hide behavior on `text-summary` events. - */ Drupal.behaviors.textSummary = { - attach: function (context, settings) { + attach: function attach(context, settings) { $(context).find('.js-text-summary').once('text-summary').each(function () { var $widget = $(this).closest('.js-text-format-wrapper'); @@ -25,13 +20,10 @@ var $full = $widget.find('.js-text-full').closest('.js-form-item'); var $fullLabel = $full.find('label').eq(0); - // Create a placeholder label when the field cardinality is greater - // than 1. if ($fullLabel.length === 0) { $fullLabel = $('<label></label>').prependTo($full); } - // Set up the edit/hide summary link. var $link = $('<span class="field-edit-link"> (<button type="button" class="link link-edit-summary">' + Drupal.t('Hide summary') + '</button>)</span>'); var $button = $link.find('button'); var toggleClick = true; @@ -40,8 +32,7 @@ $summary.hide(); $button.html(Drupal.t('Edit summary')); $link.appendTo($fullLabel); - } - else { + } else { $summary.show(); $button.html(Drupal.t('Hide summary')); $link.appendTo($summaryLabel); @@ -50,12 +41,10 @@ toggleClick = !toggleClick; }).appendTo($summaryLabel); - // If no summary is set, hide the summary field. if ($widget.find('.js-text-summary').val() === '') { $link.trigger('click'); } }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/toolbar/js/escapeAdmin.es6.js b/core/modules/toolbar/js/escapeAdmin.es6.js new file mode 100644 index 000000000000..6e8f992fd41b --- /dev/null +++ b/core/modules/toolbar/js/escapeAdmin.es6.js @@ -0,0 +1,48 @@ +/** + * @file + * Replaces the home link in toolbar with a back to site link. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + var pathInfo = drupalSettings.path; + var escapeAdminPath = sessionStorage.getItem('escapeAdminPath'); + var windowLocation = window.location; + + // Saves the last non-administrative page in the browser to be able to link + // back to it when browsing administrative pages. If there is a destination + // parameter there is not need to save the current path because the page is + // loaded within an existing "workflow". + if (!pathInfo.currentPathIsAdmin && !/destination=/.test(windowLocation.search)) { + sessionStorage.setItem('escapeAdminPath', windowLocation); + } + + /** + * Replaces the "Home" link with "Back to site" link. + * + * Back to site link points to the last non-administrative page the user + * visited within the same browser tab. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the replacement functionality to the toolbar-escape-admin element. + */ + Drupal.behaviors.escapeAdmin = { + attach: function () { + var $toolbarEscape = $('[data-toolbar-escape-admin]').once('escapeAdmin'); + if ($toolbarEscape.length && pathInfo.currentPathIsAdmin) { + if (escapeAdminPath !== null) { + $toolbarEscape.attr('href', escapeAdminPath); + } + else { + $toolbarEscape.text(Drupal.t('Home')); + } + $toolbarEscape.closest('.toolbar-tab').removeClass('hidden'); + } + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/toolbar/js/escapeAdmin.js b/core/modules/toolbar/js/escapeAdmin.js index 6e8f992fd41b..757616cb8c5b 100644 --- a/core/modules/toolbar/js/escapeAdmin.js +++ b/core/modules/toolbar/js/escapeAdmin.js @@ -1,7 +1,10 @@ /** - * @file - * Replaces the home link in toolbar with a back to site link. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/escapeAdmin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { @@ -11,38 +14,21 @@ var escapeAdminPath = sessionStorage.getItem('escapeAdminPath'); var windowLocation = window.location; - // Saves the last non-administrative page in the browser to be able to link - // back to it when browsing administrative pages. If there is a destination - // parameter there is not need to save the current path because the page is - // loaded within an existing "workflow". if (!pathInfo.currentPathIsAdmin && !/destination=/.test(windowLocation.search)) { sessionStorage.setItem('escapeAdminPath', windowLocation); } - /** - * Replaces the "Home" link with "Back to site" link. - * - * Back to site link points to the last non-administrative page the user - * visited within the same browser tab. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the replacement functionality to the toolbar-escape-admin element. - */ Drupal.behaviors.escapeAdmin = { - attach: function () { + attach: function attach() { var $toolbarEscape = $('[data-toolbar-escape-admin]').once('escapeAdmin'); if ($toolbarEscape.length && pathInfo.currentPathIsAdmin) { if (escapeAdminPath !== null) { $toolbarEscape.attr('href', escapeAdminPath); - } - else { + } else { $toolbarEscape.text(Drupal.t('Home')); } $toolbarEscape.closest('.toolbar-tab').removeClass('hidden'); } } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/toolbar/js/models/MenuModel.es6.js b/core/modules/toolbar/js/models/MenuModel.es6.js new file mode 100644 index 000000000000..072a4f141513 --- /dev/null +++ b/core/modules/toolbar/js/models/MenuModel.es6.js @@ -0,0 +1,33 @@ +/** + * @file + * A Backbone Model for collapsible menus. + */ + +(function (Backbone, Drupal) { + + 'use strict'; + + /** + * Backbone Model for collapsible menus. + * + * @constructor + * + * @augments Backbone.Model + */ + Drupal.toolbar.MenuModel = Backbone.Model.extend(/** @lends Drupal.toolbar.MenuModel# */{ + + /** + * @type {object} + * + * @prop {object} subtrees + */ + defaults: /** @lends Drupal.toolbar.MenuModel# */{ + + /** + * @type {object} + */ + subtrees: {} + } + }); + +}(Backbone, Drupal)); diff --git a/core/modules/toolbar/js/models/MenuModel.js b/core/modules/toolbar/js/models/MenuModel.js index 072a4f141513..4e221a841f59 100644 --- a/core/modules/toolbar/js/models/MenuModel.js +++ b/core/modules/toolbar/js/models/MenuModel.js @@ -1,33 +1,18 @@ /** - * @file - * A Backbone Model for collapsible menus. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/models/MenuModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Backbone, Drupal) { 'use strict'; - /** - * Backbone Model for collapsible menus. - * - * @constructor - * - * @augments Backbone.Model - */ - Drupal.toolbar.MenuModel = Backbone.Model.extend(/** @lends Drupal.toolbar.MenuModel# */{ - - /** - * @type {object} - * - * @prop {object} subtrees - */ - defaults: /** @lends Drupal.toolbar.MenuModel# */{ - - /** - * @type {object} - */ + Drupal.toolbar.MenuModel = Backbone.Model.extend({ + defaults: { subtrees: {} } }); - -}(Backbone, Drupal)); +})(Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/toolbar/js/models/ToolbarModel.es6.js b/core/modules/toolbar/js/models/ToolbarModel.es6.js new file mode 100644 index 000000000000..537601d8f712 --- /dev/null +++ b/core/modules/toolbar/js/models/ToolbarModel.es6.js @@ -0,0 +1,157 @@ +/** + * @file + * A Backbone Model for the toolbar. + */ + +(function (Backbone, Drupal) { + + 'use strict'; + + /** + * Backbone model for the toolbar. + * + * @constructor + * + * @augments Backbone.Model + */ + Drupal.toolbar.ToolbarModel = Backbone.Model.extend(/** @lends Drupal.toolbar.ToolbarModel# */{ + + /** + * @type {object} + * + * @prop activeTab + * @prop activeTray + * @prop isOriented + * @prop isFixed + * @prop areSubtreesLoaded + * @prop isViewportOverflowConstrained + * @prop orientation + * @prop locked + * @prop isTrayToggleVisible + * @prop height + * @prop offsets + */ + defaults: /** @lends Drupal.toolbar.ToolbarModel# */{ + + /** + * The active toolbar tab. All other tabs should be inactive under + * normal circumstances. It will remain active across page loads. The + * active item is stored as an ID selector e.g. '#toolbar-item--1'. + * + * @type {string} + */ + activeTab: null, + + /** + * Represents whether a tray is open or not. Stored as an ID selector e.g. + * '#toolbar-item--1-tray'. + * + * @type {string} + */ + activeTray: null, + + /** + * Indicates whether the toolbar is displayed in an oriented fashion, + * either horizontal or vertical. + * + * @type {bool} + */ + isOriented: false, + + /** + * Indicates whether the toolbar is positioned absolute (false) or fixed + * (true). + * + * @type {bool} + */ + isFixed: false, + + /** + * Menu subtrees are loaded through an AJAX request only when the Toolbar + * is set to a vertical orientation. + * + * @type {bool} + */ + areSubtreesLoaded: false, + + /** + * If the viewport overflow becomes constrained, isFixed must be true so + * that elements in the trays aren't lost off-screen and impossible to + * get to. + * + * @type {bool} + */ + isViewportOverflowConstrained: false, + + /** + * The orientation of the active tray. + * + * @type {string} + */ + orientation: 'vertical', + + /** + * A tray is locked if a user toggled it to vertical. Otherwise a tray + * will switch between vertical and horizontal orientation based on the + * configured breakpoints. The locked state will be maintained across page + * loads. + * + * @type {bool} + */ + locked: false, + + /** + * Indicates whether the tray orientation toggle is visible. + * + * @type {bool} + */ + isTrayToggleVisible: false, + + /** + * The height of the toolbar. + * + * @type {number} + */ + height: null, + + /** + * The current viewport offsets determined by {@link Drupal.displace}. The + * offsets suggest how a module might position is components relative to + * the viewport. + * + * @type {object} + * + * @prop {number} top + * @prop {number} right + * @prop {number} bottom + * @prop {number} left + */ + offsets: { + top: 0, + right: 0, + bottom: 0, + left: 0 + } + }, + + /** + * @inheritdoc + * + * @param {object} attributes + * Attributes for the toolbar. + * @param {object} options + * Options for the toolbar. + * + * @return {string|undefined} + * Returns an error message if validation failed. + */ + validate: function (attributes, options) { + // Prevent the orientation being set to horizontal if it is locked, unless + // override has not been passed as an option. + if (attributes.orientation === 'horizontal' && this.get('locked') && !options.override) { + return Drupal.t('The toolbar cannot be set to a horizontal orientation when it is locked.'); + } + } + }); + +}(Backbone, Drupal)); diff --git a/core/modules/toolbar/js/models/ToolbarModel.js b/core/modules/toolbar/js/models/ToolbarModel.js index 537601d8f712..45e254f7233f 100644 --- a/core/modules/toolbar/js/models/ToolbarModel.js +++ b/core/modules/toolbar/js/models/ToolbarModel.js @@ -1,131 +1,37 @@ /** - * @file - * A Backbone Model for the toolbar. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/models/ToolbarModel.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Backbone, Drupal) { 'use strict'; - /** - * Backbone model for the toolbar. - * - * @constructor - * - * @augments Backbone.Model - */ - Drupal.toolbar.ToolbarModel = Backbone.Model.extend(/** @lends Drupal.toolbar.ToolbarModel# */{ - - /** - * @type {object} - * - * @prop activeTab - * @prop activeTray - * @prop isOriented - * @prop isFixed - * @prop areSubtreesLoaded - * @prop isViewportOverflowConstrained - * @prop orientation - * @prop locked - * @prop isTrayToggleVisible - * @prop height - * @prop offsets - */ - defaults: /** @lends Drupal.toolbar.ToolbarModel# */{ - - /** - * The active toolbar tab. All other tabs should be inactive under - * normal circumstances. It will remain active across page loads. The - * active item is stored as an ID selector e.g. '#toolbar-item--1'. - * - * @type {string} - */ + Drupal.toolbar.ToolbarModel = Backbone.Model.extend({ + defaults: { activeTab: null, - /** - * Represents whether a tray is open or not. Stored as an ID selector e.g. - * '#toolbar-item--1-tray'. - * - * @type {string} - */ activeTray: null, - /** - * Indicates whether the toolbar is displayed in an oriented fashion, - * either horizontal or vertical. - * - * @type {bool} - */ isOriented: false, - /** - * Indicates whether the toolbar is positioned absolute (false) or fixed - * (true). - * - * @type {bool} - */ isFixed: false, - /** - * Menu subtrees are loaded through an AJAX request only when the Toolbar - * is set to a vertical orientation. - * - * @type {bool} - */ areSubtreesLoaded: false, - /** - * If the viewport overflow becomes constrained, isFixed must be true so - * that elements in the trays aren't lost off-screen and impossible to - * get to. - * - * @type {bool} - */ isViewportOverflowConstrained: false, - /** - * The orientation of the active tray. - * - * @type {string} - */ orientation: 'vertical', - /** - * A tray is locked if a user toggled it to vertical. Otherwise a tray - * will switch between vertical and horizontal orientation based on the - * configured breakpoints. The locked state will be maintained across page - * loads. - * - * @type {bool} - */ locked: false, - /** - * Indicates whether the tray orientation toggle is visible. - * - * @type {bool} - */ isTrayToggleVisible: false, - /** - * The height of the toolbar. - * - * @type {number} - */ height: null, - /** - * The current viewport offsets determined by {@link Drupal.displace}. The - * offsets suggest how a module might position is components relative to - * the viewport. - * - * @type {object} - * - * @prop {number} top - * @prop {number} right - * @prop {number} bottom - * @prop {number} left - */ offsets: { top: 0, right: 0, @@ -134,24 +40,10 @@ } }, - /** - * @inheritdoc - * - * @param {object} attributes - * Attributes for the toolbar. - * @param {object} options - * Options for the toolbar. - * - * @return {string|undefined} - * Returns an error message if validation failed. - */ - validate: function (attributes, options) { - // Prevent the orientation being set to horizontal if it is locked, unless - // override has not been passed as an option. + validate: function validate(attributes, options) { if (attributes.orientation === 'horizontal' && this.get('locked') && !options.override) { return Drupal.t('The toolbar cannot be set to a horizontal orientation when it is locked.'); } } }); - -}(Backbone, Drupal)); +})(Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/toolbar/js/toolbar.es6.js b/core/modules/toolbar/js/toolbar.es6.js new file mode 100644 index 000000000000..8accad975420 --- /dev/null +++ b/core/modules/toolbar/js/toolbar.es6.js @@ -0,0 +1,257 @@ +/** + * @file + * Defines the behavior of the Drupal administration toolbar. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + // Merge run-time settings with the defaults. + var options = $.extend( + { + breakpoints: { + 'toolbar.narrow': '', + 'toolbar.standard': '', + 'toolbar.wide': '' + } + }, + drupalSettings.toolbar, + // Merge strings on top of drupalSettings so that they are not mutable. + { + strings: { + horizontal: Drupal.t('Horizontal orientation'), + vertical: Drupal.t('Vertical orientation') + } + } + ); + + /** + * Registers tabs with the toolbar. + * + * The Drupal toolbar allows modules to register top-level tabs. These may + * point directly to a resource or toggle the visibility of a tray. + * + * Modules register tabs with hook_toolbar(). + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the toolbar rendering functionality to the toolbar element. + */ + Drupal.behaviors.toolbar = { + attach: function (context) { + // Verify that the user agent understands media queries. Complex admin + // toolbar layouts require media query support. + if (!window.matchMedia('only screen').matches) { + return; + } + // Process the administrative toolbar. + $(context).find('#toolbar-administration').once('toolbar').each(function () { + + // Establish the toolbar models and views. + var model = Drupal.toolbar.models.toolbarModel = new Drupal.toolbar.ToolbarModel({ + locked: JSON.parse(localStorage.getItem('Drupal.toolbar.trayVerticalLocked')) || false, + activeTab: document.getElementById(JSON.parse(localStorage.getItem('Drupal.toolbar.activeTabID'))) + }); + Drupal.toolbar.views.toolbarVisualView = new Drupal.toolbar.ToolbarVisualView({ + el: this, + model: model, + strings: options.strings + }); + Drupal.toolbar.views.toolbarAuralView = new Drupal.toolbar.ToolbarAuralView({ + el: this, + model: model, + strings: options.strings + }); + Drupal.toolbar.views.bodyVisualView = new Drupal.toolbar.BodyVisualView({ + el: this, + model: model + }); + + // Render collapsible menus. + var menuModel = Drupal.toolbar.models.menuModel = new Drupal.toolbar.MenuModel(); + Drupal.toolbar.views.menuVisualView = new Drupal.toolbar.MenuVisualView({ + el: $(this).find('.toolbar-menu-administration').get(0), + model: menuModel, + strings: options.strings + }); + + // Handle the resolution of Drupal.toolbar.setSubtrees. + // This is handled with a deferred so that the function may be invoked + // asynchronously. + Drupal.toolbar.setSubtrees.done(function (subtrees) { + menuModel.set('subtrees', subtrees); + var theme = drupalSettings.ajaxPageState.theme; + localStorage.setItem('Drupal.toolbar.subtrees.' + theme, JSON.stringify(subtrees)); + // Indicate on the toolbarModel that subtrees are now loaded. + model.set('areSubtreesLoaded', true); + }); + + // Attach a listener to the configured media query breakpoints. + for (var label in options.breakpoints) { + if (options.breakpoints.hasOwnProperty(label)) { + var mq = options.breakpoints[label]; + var mql = Drupal.toolbar.mql[label] = window.matchMedia(mq); + // Curry the model and the label of the media query breakpoint to + // the mediaQueryChangeHandler function. + mql.addListener(Drupal.toolbar.mediaQueryChangeHandler.bind(null, model, label)); + // Fire the mediaQueryChangeHandler for each configured breakpoint + // so that they process once. + Drupal.toolbar.mediaQueryChangeHandler.call(null, model, label, mql); + } + } + + // Trigger an initial attempt to load menu subitems. This first attempt + // is made after the media query handlers have had an opportunity to + // process. The toolbar starts in the vertical orientation by default, + // unless the viewport is wide enough to accommodate a horizontal + // orientation. Thus we give the Toolbar a chance to determine if it + // should be set to horizontal orientation before attempting to load + // menu subtrees. + Drupal.toolbar.views.toolbarVisualView.loadSubtrees(); + + $(document) + // Update the model when the viewport offset changes. + .on('drupalViewportOffsetChange.toolbar', function (event, offsets) { + model.set('offsets', offsets); + }); + + // Broadcast model changes to other modules. + model + .on('change:orientation', function (model, orientation) { + $(document).trigger('drupalToolbarOrientationChange', orientation); + }) + .on('change:activeTab', function (model, tab) { + $(document).trigger('drupalToolbarTabChange', tab); + }) + .on('change:activeTray', function (model, tray) { + $(document).trigger('drupalToolbarTrayChange', tray); + }); + + // If the toolbar's orientation is horizontal and no active tab is + // defined then show the tray of the first toolbar tab by default (but + // not the first 'Home' toolbar tab). + if (Drupal.toolbar.models.toolbarModel.get('orientation') === 'horizontal' && Drupal.toolbar.models.toolbarModel.get('activeTab') === null) { + Drupal.toolbar.models.toolbarModel.set({ + activeTab: $('.toolbar-bar .toolbar-tab:not(.home-toolbar-tab) a').get(0) + }); + } + }); + } + }; + + /** + * Toolbar methods of Backbone objects. + * + * @namespace + */ + Drupal.toolbar = { + + /** + * A hash of View instances. + * + * @type {object.<string, Backbone.View>} + */ + views: {}, + + /** + * A hash of Model instances. + * + * @type {object.<string, Backbone.Model>} + */ + models: {}, + + /** + * A hash of MediaQueryList objects tracked by the toolbar. + * + * @type {object.<string, object>} + */ + mql: {}, + + /** + * Accepts a list of subtree menu elements. + * + * A deferred object that is resolved by an inlined JavaScript callback. + * + * @type {jQuery.Deferred} + * + * @see toolbar_subtrees_jsonp(). + */ + setSubtrees: new $.Deferred(), + + /** + * Respond to configured narrow media query changes. + * + * @param {Drupal.toolbar.ToolbarModel} model + * A toolbar model + * @param {string} label + * Media query label. + * @param {object} mql + * A MediaQueryList object. + */ + mediaQueryChangeHandler: function (model, label, mql) { + switch (label) { + case 'toolbar.narrow': + model.set({ + isOriented: mql.matches, + isTrayToggleVisible: false + }); + // If the toolbar doesn't have an explicit orientation yet, or if the + // narrow media query doesn't match then set the orientation to + // vertical. + if (!mql.matches || !model.get('orientation')) { + model.set({orientation: 'vertical'}, {validate: true}); + } + break; + + case 'toolbar.standard': + model.set({ + isFixed: mql.matches + }); + break; + + case 'toolbar.wide': + model.set({ + orientation: ((mql.matches) ? 'horizontal' : 'vertical') + }, {validate: true}); + // The tray orientation toggle visibility does not need to be + // validated. + model.set({ + isTrayToggleVisible: mql.matches + }); + break; + + default: + break; + } + } + }; + + /** + * A toggle is an interactive element often bound to a click handler. + * + * @return {string} + * A string representing a DOM fragment. + */ + Drupal.theme.toolbarOrientationToggle = function () { + return '<div class="toolbar-toggle-orientation"><div class="toolbar-lining">' + + '<button class="toolbar-icon" type="button"></button>' + + '</div></div>'; + }; + + /** + * Ajax command to set the toolbar subtrees. + * + * @param {Drupal.Ajax} ajax + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * JSON response from the Ajax request. + * @param {number} [status] + * XMLHttpRequest status. + */ + Drupal.AjaxCommands.prototype.setToolbarSubtrees = function (ajax, response, status) { + Drupal.toolbar.setSubtrees.resolve(response.subtrees); + }; + +}(jQuery, Drupal, drupalSettings)); diff --git a/core/modules/toolbar/js/toolbar.js b/core/modules/toolbar/js/toolbar.js index 8accad975420..a0819f4d434b 100644 --- a/core/modules/toolbar/js/toolbar.js +++ b/core/modules/toolbar/js/toolbar.js @@ -1,55 +1,35 @@ /** - * @file - * Defines the behavior of the Drupal administration toolbar. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/toolbar.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - // Merge run-time settings with the defaults. - var options = $.extend( - { - breakpoints: { - 'toolbar.narrow': '', - 'toolbar.standard': '', - 'toolbar.wide': '' - } - }, - drupalSettings.toolbar, - // Merge strings on top of drupalSettings so that they are not mutable. - { - strings: { - horizontal: Drupal.t('Horizontal orientation'), - vertical: Drupal.t('Vertical orientation') - } + var options = $.extend({ + breakpoints: { + 'toolbar.narrow': '', + 'toolbar.standard': '', + 'toolbar.wide': '' } - ); + }, drupalSettings.toolbar, { + strings: { + horizontal: Drupal.t('Horizontal orientation'), + vertical: Drupal.t('Vertical orientation') + } + }); - /** - * Registers tabs with the toolbar. - * - * The Drupal toolbar allows modules to register top-level tabs. These may - * point directly to a resource or toggle the visibility of a tray. - * - * Modules register tabs with hook_toolbar(). - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the toolbar rendering functionality to the toolbar element. - */ Drupal.behaviors.toolbar = { - attach: function (context) { - // Verify that the user agent understands media queries. Complex admin - // toolbar layouts require media query support. + attach: function attach(context) { if (!window.matchMedia('only screen').matches) { return; } - // Process the administrative toolbar. - $(context).find('#toolbar-administration').once('toolbar').each(function () { - // Establish the toolbar models and views. + $(context).find('#toolbar-administration').once('toolbar').each(function () { var model = Drupal.toolbar.models.toolbarModel = new Drupal.toolbar.ToolbarModel({ locked: JSON.parse(localStorage.getItem('Drupal.toolbar.trayVerticalLocked')) || false, activeTab: document.getElementById(JSON.parse(localStorage.getItem('Drupal.toolbar.activeTabID'))) @@ -69,7 +49,6 @@ model: model }); - // Render collapsible menus. var menuModel = Drupal.toolbar.models.menuModel = new Drupal.toolbar.MenuModel(); Drupal.toolbar.views.menuVisualView = new Drupal.toolbar.MenuVisualView({ el: $(this).find('.toolbar-menu-administration').get(0), @@ -77,61 +56,39 @@ strings: options.strings }); - // Handle the resolution of Drupal.toolbar.setSubtrees. - // This is handled with a deferred so that the function may be invoked - // asynchronously. Drupal.toolbar.setSubtrees.done(function (subtrees) { menuModel.set('subtrees', subtrees); var theme = drupalSettings.ajaxPageState.theme; localStorage.setItem('Drupal.toolbar.subtrees.' + theme, JSON.stringify(subtrees)); - // Indicate on the toolbarModel that subtrees are now loaded. + model.set('areSubtreesLoaded', true); }); - // Attach a listener to the configured media query breakpoints. for (var label in options.breakpoints) { if (options.breakpoints.hasOwnProperty(label)) { var mq = options.breakpoints[label]; var mql = Drupal.toolbar.mql[label] = window.matchMedia(mq); - // Curry the model and the label of the media query breakpoint to - // the mediaQueryChangeHandler function. + mql.addListener(Drupal.toolbar.mediaQueryChangeHandler.bind(null, model, label)); - // Fire the mediaQueryChangeHandler for each configured breakpoint - // so that they process once. + Drupal.toolbar.mediaQueryChangeHandler.call(null, model, label, mql); } } - // Trigger an initial attempt to load menu subitems. This first attempt - // is made after the media query handlers have had an opportunity to - // process. The toolbar starts in the vertical orientation by default, - // unless the viewport is wide enough to accommodate a horizontal - // orientation. Thus we give the Toolbar a chance to determine if it - // should be set to horizontal orientation before attempting to load - // menu subtrees. Drupal.toolbar.views.toolbarVisualView.loadSubtrees(); - $(document) - // Update the model when the viewport offset changes. - .on('drupalViewportOffsetChange.toolbar', function (event, offsets) { - model.set('offsets', offsets); - }); + $(document).on('drupalViewportOffsetChange.toolbar', function (event, offsets) { + model.set('offsets', offsets); + }); - // Broadcast model changes to other modules. - model - .on('change:orientation', function (model, orientation) { - $(document).trigger('drupalToolbarOrientationChange', orientation); - }) - .on('change:activeTab', function (model, tab) { - $(document).trigger('drupalToolbarTabChange', tab); - }) - .on('change:activeTray', function (model, tray) { - $(document).trigger('drupalToolbarTrayChange', tray); - }); + model.on('change:orientation', function (model, orientation) { + $(document).trigger('drupalToolbarOrientationChange', orientation); + }).on('change:activeTab', function (model, tab) { + $(document).trigger('drupalToolbarTabChange', tab); + }).on('change:activeTray', function (model, tray) { + $(document).trigger('drupalToolbarTrayChange', tray); + }); - // If the toolbar's orientation is horizontal and no active tab is - // defined then show the tray of the first toolbar tab by default (but - // not the first 'Home' toolbar tab). if (Drupal.toolbar.models.toolbarModel.get('orientation') === 'horizontal' && Drupal.toolbar.models.toolbarModel.get('activeTab') === null) { Drupal.toolbar.models.toolbarModel.set({ activeTab: $('.toolbar-bar .toolbar-tab:not(.home-toolbar-tab) a').get(0) @@ -141,67 +98,25 @@ } }; - /** - * Toolbar methods of Backbone objects. - * - * @namespace - */ Drupal.toolbar = { - - /** - * A hash of View instances. - * - * @type {object.<string, Backbone.View>} - */ views: {}, - /** - * A hash of Model instances. - * - * @type {object.<string, Backbone.Model>} - */ models: {}, - /** - * A hash of MediaQueryList objects tracked by the toolbar. - * - * @type {object.<string, object>} - */ mql: {}, - /** - * Accepts a list of subtree menu elements. - * - * A deferred object that is resolved by an inlined JavaScript callback. - * - * @type {jQuery.Deferred} - * - * @see toolbar_subtrees_jsonp(). - */ setSubtrees: new $.Deferred(), - /** - * Respond to configured narrow media query changes. - * - * @param {Drupal.toolbar.ToolbarModel} model - * A toolbar model - * @param {string} label - * Media query label. - * @param {object} mql - * A MediaQueryList object. - */ - mediaQueryChangeHandler: function (model, label, mql) { + mediaQueryChangeHandler: function mediaQueryChangeHandler(model, label, mql) { switch (label) { case 'toolbar.narrow': model.set({ isOriented: mql.matches, isTrayToggleVisible: false }); - // If the toolbar doesn't have an explicit orientation yet, or if the - // narrow media query doesn't match then set the orientation to - // vertical. + if (!mql.matches || !model.get('orientation')) { - model.set({orientation: 'vertical'}, {validate: true}); + model.set({ orientation: 'vertical' }, { validate: true }); } break; @@ -213,10 +128,9 @@ case 'toolbar.wide': model.set({ - orientation: ((mql.matches) ? 'horizontal' : 'vertical') - }, {validate: true}); - // The tray orientation toggle visibility does not need to be - // validated. + orientation: mql.matches ? 'horizontal' : 'vertical' + }, { validate: true }); + model.set({ isTrayToggleVisible: mql.matches }); @@ -228,30 +142,11 @@ } }; - /** - * A toggle is an interactive element often bound to a click handler. - * - * @return {string} - * A string representing a DOM fragment. - */ Drupal.theme.toolbarOrientationToggle = function () { - return '<div class="toolbar-toggle-orientation"><div class="toolbar-lining">' + - '<button class="toolbar-icon" type="button"></button>' + - '</div></div>'; + return '<div class="toolbar-toggle-orientation"><div class="toolbar-lining">' + '<button class="toolbar-icon" type="button"></button>' + '</div></div>'; }; - /** - * Ajax command to set the toolbar subtrees. - * - * @param {Drupal.Ajax} ajax - * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. - * @param {object} response - * JSON response from the Ajax request. - * @param {number} [status] - * XMLHttpRequest status. - */ Drupal.AjaxCommands.prototype.setToolbarSubtrees = function (ajax, response, status) { Drupal.toolbar.setSubtrees.resolve(response.subtrees); }; - -}(jQuery, Drupal, drupalSettings)); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/toolbar/js/toolbar.menu.es6.js b/core/modules/toolbar/js/toolbar.menu.es6.js new file mode 100644 index 000000000000..0ca25a9b0566 --- /dev/null +++ b/core/modules/toolbar/js/toolbar.menu.es6.js @@ -0,0 +1,197 @@ +/** + * @file + * Builds a nested accordion widget. + * + * Invoke on an HTML list element with the jQuery plugin pattern. + * + * @example + * $('.toolbar-menu').drupalToolbarMenu(); + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Store the open menu tray. + */ + var activeItem = Drupal.url(drupalSettings.path.currentPath); + + $.fn.drupalToolbarMenu = function () { + + var ui = { + handleOpen: Drupal.t('Extend'), + handleClose: Drupal.t('Collapse') + }; + + /** + * Handle clicks from the disclosure button on an item with sub-items. + * + * @param {Object} event + * A jQuery Event object. + */ + function toggleClickHandler(event) { + var $toggle = $(event.target); + var $item = $toggle.closest('li'); + // Toggle the list item. + toggleList($item); + // Close open sibling menus. + var $openItems = $item.siblings().filter('.open'); + toggleList($openItems, false); + } + + /** + * Handle clicks from a menu item link. + * + * @param {Object} event + * A jQuery Event object. + */ + function linkClickHandler(event) { + // If the toolbar is positioned fixed (and therefore hiding content + // underneath), then users expect clicks in the administration menu tray + // to take them to that destination but for the menu tray to be closed + // after clicking: otherwise the toolbar itself is obstructing the view + // of the destination they chose. + if (!Drupal.toolbar.models.toolbarModel.get('isFixed')) { + Drupal.toolbar.models.toolbarModel.set('activeTab', null); + } + // Stopping propagation to make sure that once a toolbar-box is clicked + // (the whitespace part), the page is not redirected anymore. + event.stopPropagation(); + } + + /** + * Toggle the open/close state of a list is a menu. + * + * @param {jQuery} $item + * The li item to be toggled. + * + * @param {Boolean} switcher + * A flag that forces toggleClass to add or a remove a class, rather than + * simply toggling its presence. + */ + function toggleList($item, switcher) { + var $toggle = $item.children('.toolbar-box').children('.toolbar-handle'); + switcher = (typeof switcher !== 'undefined') ? switcher : !$item.hasClass('open'); + // Toggle the item open state. + $item.toggleClass('open', switcher); + // Twist the toggle. + $toggle.toggleClass('open', switcher); + // Adjust the toggle text. + $toggle + .find('.action') + // Expand Structure, Collapse Structure. + .text((switcher) ? ui.handleClose : ui.handleOpen); + } + + /** + * Add markup to the menu elements. + * + * Items with sub-elements have a list toggle attached to them. Menu item + * links and the corresponding list toggle are wrapped with in a div + * classed with .toolbar-box. The .toolbar-box div provides a positioning + * context for the item list toggle. + * + * @param {jQuery} $menu + * The root of the menu to be initialized. + */ + function initItems($menu) { + var options = { + class: 'toolbar-icon toolbar-handle', + action: ui.handleOpen, + text: '' + }; + // Initialize items and their links. + $menu.find('li > a').wrap('<div class="toolbar-box">'); + // Add a handle to each list item if it has a menu. + $menu.find('li').each(function (index, element) { + var $item = $(element); + if ($item.children('ul.toolbar-menu').length) { + var $box = $item.children('.toolbar-box'); + options.text = Drupal.t('@label', {'@label': $box.find('a').text()}); + $item.children('.toolbar-box') + .append(Drupal.theme('toolbarMenuItemToggle', options)); + } + }); + } + + /** + * Adds a level class to each list based on its depth in the menu. + * + * This function is called recursively on each sub level of lists elements + * until the depth of the menu is exhausted. + * + * @param {jQuery} $lists + * A jQuery object of ul elements. + * + * @param {number} level + * The current level number to be assigned to the list elements. + */ + function markListLevels($lists, level) { + level = (!level) ? 1 : level; + var $lis = $lists.children('li').addClass('level-' + level); + $lists = $lis.children('ul'); + if ($lists.length) { + markListLevels($lists, level + 1); + } + } + + /** + * On page load, open the active menu item. + * + * Marks the trail of the active link in the menu back to the root of the + * menu with .menu-item--active-trail. + * + * @param {jQuery} $menu + * The root of the menu. + */ + function openActiveItem($menu) { + var pathItem = $menu.find('a[href="' + location.pathname + '"]'); + if (pathItem.length && !activeItem) { + activeItem = location.pathname; + } + if (activeItem) { + var $activeItem = $menu.find('a[href="' + activeItem + '"]').addClass('menu-item--active'); + var $activeTrail = $activeItem.parentsUntil('.root', 'li').addClass('menu-item--active-trail'); + toggleList($activeTrail, true); + } + } + + // Return the jQuery object. + return this.each(function (selector) { + var $menu = $(this).once('toolbar-menu'); + if ($menu.length) { + // Bind event handlers. + $menu + .on('click.toolbar', '.toolbar-box', toggleClickHandler) + .on('click.toolbar', '.toolbar-box a', linkClickHandler); + + $menu.addClass('root'); + initItems($menu); + markListLevels($menu); + // Restore previous and active states. + openActiveItem($menu); + } + }); + }; + + /** + * A toggle is an interactive element often bound to a click handler. + * + * @param {object} options + * Options for the button. + * @param {string} options.class + * Class to set on the button. + * @param {string} options.action + * Action for the button. + * @param {string} options.text + * Used as label for the button. + * + * @return {string} + * A string representing a DOM fragment. + */ + Drupal.theme.toolbarMenuItemToggle = function (options) { + return '<button class="' + options['class'] + '"><span class="action">' + options.action + '</span><span class="label">' + options.text + '</span></button>'; + }; + +}(jQuery, Drupal, drupalSettings)); diff --git a/core/modules/toolbar/js/toolbar.menu.js b/core/modules/toolbar/js/toolbar.menu.js index 0ca25a9b0566..97b142566183 100644 --- a/core/modules/toolbar/js/toolbar.menu.js +++ b/core/modules/toolbar/js/toolbar.menu.js @@ -1,20 +1,15 @@ /** - * @file - * Builds a nested accordion widget. - * - * Invoke on an HTML list element with the jQuery plugin pattern. - * - * @example - * $('.toolbar-menu').drupalToolbarMenu(); - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/toolbar.menu.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Store the open menu tray. - */ var activeItem = Drupal.url(drupalSettings.path.currentPath); $.fn.drupalToolbarMenu = function () { @@ -24,111 +19,56 @@ handleClose: Drupal.t('Collapse') }; - /** - * Handle clicks from the disclosure button on an item with sub-items. - * - * @param {Object} event - * A jQuery Event object. - */ function toggleClickHandler(event) { var $toggle = $(event.target); var $item = $toggle.closest('li'); - // Toggle the list item. + toggleList($item); - // Close open sibling menus. + var $openItems = $item.siblings().filter('.open'); toggleList($openItems, false); } - /** - * Handle clicks from a menu item link. - * - * @param {Object} event - * A jQuery Event object. - */ function linkClickHandler(event) { - // If the toolbar is positioned fixed (and therefore hiding content - // underneath), then users expect clicks in the administration menu tray - // to take them to that destination but for the menu tray to be closed - // after clicking: otherwise the toolbar itself is obstructing the view - // of the destination they chose. if (!Drupal.toolbar.models.toolbarModel.get('isFixed')) { Drupal.toolbar.models.toolbarModel.set('activeTab', null); } - // Stopping propagation to make sure that once a toolbar-box is clicked - // (the whitespace part), the page is not redirected anymore. + event.stopPropagation(); } - /** - * Toggle the open/close state of a list is a menu. - * - * @param {jQuery} $item - * The li item to be toggled. - * - * @param {Boolean} switcher - * A flag that forces toggleClass to add or a remove a class, rather than - * simply toggling its presence. - */ function toggleList($item, switcher) { var $toggle = $item.children('.toolbar-box').children('.toolbar-handle'); - switcher = (typeof switcher !== 'undefined') ? switcher : !$item.hasClass('open'); - // Toggle the item open state. + switcher = typeof switcher !== 'undefined' ? switcher : !$item.hasClass('open'); + $item.toggleClass('open', switcher); - // Twist the toggle. + $toggle.toggleClass('open', switcher); - // Adjust the toggle text. - $toggle - .find('.action') - // Expand Structure, Collapse Structure. - .text((switcher) ? ui.handleClose : ui.handleOpen); + + $toggle.find('.action').text(switcher ? ui.handleClose : ui.handleOpen); } - /** - * Add markup to the menu elements. - * - * Items with sub-elements have a list toggle attached to them. Menu item - * links and the corresponding list toggle are wrapped with in a div - * classed with .toolbar-box. The .toolbar-box div provides a positioning - * context for the item list toggle. - * - * @param {jQuery} $menu - * The root of the menu to be initialized. - */ function initItems($menu) { var options = { class: 'toolbar-icon toolbar-handle', action: ui.handleOpen, text: '' }; - // Initialize items and their links. + $menu.find('li > a').wrap('<div class="toolbar-box">'); - // Add a handle to each list item if it has a menu. + $menu.find('li').each(function (index, element) { var $item = $(element); if ($item.children('ul.toolbar-menu').length) { var $box = $item.children('.toolbar-box'); - options.text = Drupal.t('@label', {'@label': $box.find('a').text()}); - $item.children('.toolbar-box') - .append(Drupal.theme('toolbarMenuItemToggle', options)); + options.text = Drupal.t('@label', { '@label': $box.find('a').text() }); + $item.children('.toolbar-box').append(Drupal.theme('toolbarMenuItemToggle', options)); } }); } - /** - * Adds a level class to each list based on its depth in the menu. - * - * This function is called recursively on each sub level of lists elements - * until the depth of the menu is exhausted. - * - * @param {jQuery} $lists - * A jQuery object of ul elements. - * - * @param {number} level - * The current level number to be assigned to the list elements. - */ function markListLevels($lists, level) { - level = (!level) ? 1 : level; + level = !level ? 1 : level; var $lis = $lists.children('li').addClass('level-' + level); $lists = $lis.children('ul'); if ($lists.length) { @@ -136,15 +76,6 @@ } } - /** - * On page load, open the active menu item. - * - * Marks the trail of the active link in the menu back to the root of the - * menu with .menu-item--active-trail. - * - * @param {jQuery} $menu - * The root of the menu. - */ function openActiveItem($menu) { var pathItem = $menu.find('a[href="' + location.pathname + '"]'); if (pathItem.length && !activeItem) { @@ -157,41 +88,21 @@ } } - // Return the jQuery object. return this.each(function (selector) { var $menu = $(this).once('toolbar-menu'); if ($menu.length) { - // Bind event handlers. - $menu - .on('click.toolbar', '.toolbar-box', toggleClickHandler) - .on('click.toolbar', '.toolbar-box a', linkClickHandler); + $menu.on('click.toolbar', '.toolbar-box', toggleClickHandler).on('click.toolbar', '.toolbar-box a', linkClickHandler); $menu.addClass('root'); initItems($menu); markListLevels($menu); - // Restore previous and active states. + openActiveItem($menu); } }); }; - /** - * A toggle is an interactive element often bound to a click handler. - * - * @param {object} options - * Options for the button. - * @param {string} options.class - * Class to set on the button. - * @param {string} options.action - * Action for the button. - * @param {string} options.text - * Used as label for the button. - * - * @return {string} - * A string representing a DOM fragment. - */ Drupal.theme.toolbarMenuItemToggle = function (options) { return '<button class="' + options['class'] + '"><span class="action">' + options.action + '</span><span class="label">' + options.text + '</span></button>'; }; - -}(jQuery, Drupal, drupalSettings)); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/toolbar/js/views/BodyVisualView.es6.js b/core/modules/toolbar/js/views/BodyVisualView.es6.js new file mode 100644 index 000000000000..64593c9cf914 --- /dev/null +++ b/core/modules/toolbar/js/views/BodyVisualView.es6.js @@ -0,0 +1,53 @@ +/** + * @file + * A Backbone view for the body element. + */ + +(function ($, Drupal, Backbone) { + + 'use strict'; + + Drupal.toolbar.BodyVisualView = Backbone.View.extend(/** @lends Drupal.toolbar.BodyVisualView# */{ + + /** + * Adjusts the body element with the toolbar position and dimension changes. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.listenTo(this.model, 'change:orientation change:offsets change:activeTray change:isOriented change:isFixed change:isViewportOverflowConstrained', this.render); + }, + + /** + * @inheritdoc + */ + render: function () { + var $body = $('body'); + var orientation = this.model.get('orientation'); + var isOriented = this.model.get('isOriented'); + var isViewportOverflowConstrained = this.model.get('isViewportOverflowConstrained'); + + $body + // We are using JavaScript to control media-query handling for two + // reasons: (1) Using JavaScript let's us leverage the breakpoint + // configurations and (2) the CSS is really complex if we try to hide + // some styling from browsers that don't understand CSS media queries. + // If we drive the CSS from classes added through JavaScript, + // then the CSS becomes simpler and more robust. + .toggleClass('toolbar-vertical', (orientation === 'vertical')) + .toggleClass('toolbar-horizontal', (isOriented && orientation === 'horizontal')) + // When the toolbar is fixed, it will not scroll with page scrolling. + .toggleClass('toolbar-fixed', (isViewportOverflowConstrained || this.model.get('isFixed'))) + // Toggle the toolbar-tray-open class on the body element. The class is + // applied when a toolbar tray is active. Padding might be applied to + // the body element to prevent the tray from overlapping content. + .toggleClass('toolbar-tray-open', !!this.model.get('activeTray')) + // Apply padding to the top of the body to offset the placement of the + // toolbar bar element. + .css('padding-top', this.model.get('offsets').top); + } + }); + +}(jQuery, Drupal, Backbone)); diff --git a/core/modules/toolbar/js/views/BodyVisualView.js b/core/modules/toolbar/js/views/BodyVisualView.js index 64593c9cf914..e4aa12391d55 100644 --- a/core/modules/toolbar/js/views/BodyVisualView.js +++ b/core/modules/toolbar/js/views/BodyVisualView.js @@ -1,53 +1,27 @@ /** - * @file - * A Backbone view for the body element. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/views/BodyVisualView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, Backbone) { 'use strict'; - Drupal.toolbar.BodyVisualView = Backbone.View.extend(/** @lends Drupal.toolbar.BodyVisualView# */{ - - /** - * Adjusts the body element with the toolbar position and dimension changes. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + Drupal.toolbar.BodyVisualView = Backbone.View.extend({ + initialize: function initialize() { this.listenTo(this.model, 'change:orientation change:offsets change:activeTray change:isOriented change:isFixed change:isViewportOverflowConstrained', this.render); }, - /** - * @inheritdoc - */ - render: function () { + render: function render() { var $body = $('body'); var orientation = this.model.get('orientation'); var isOriented = this.model.get('isOriented'); var isViewportOverflowConstrained = this.model.get('isViewportOverflowConstrained'); - $body - // We are using JavaScript to control media-query handling for two - // reasons: (1) Using JavaScript let's us leverage the breakpoint - // configurations and (2) the CSS is really complex if we try to hide - // some styling from browsers that don't understand CSS media queries. - // If we drive the CSS from classes added through JavaScript, - // then the CSS becomes simpler and more robust. - .toggleClass('toolbar-vertical', (orientation === 'vertical')) - .toggleClass('toolbar-horizontal', (isOriented && orientation === 'horizontal')) - // When the toolbar is fixed, it will not scroll with page scrolling. - .toggleClass('toolbar-fixed', (isViewportOverflowConstrained || this.model.get('isFixed'))) - // Toggle the toolbar-tray-open class on the body element. The class is - // applied when a toolbar tray is active. Padding might be applied to - // the body element to prevent the tray from overlapping content. - .toggleClass('toolbar-tray-open', !!this.model.get('activeTray')) - // Apply padding to the top of the body to offset the placement of the - // toolbar bar element. - .css('padding-top', this.model.get('offsets').top); + $body.toggleClass('toolbar-vertical', orientation === 'vertical').toggleClass('toolbar-horizontal', isOriented && orientation === 'horizontal').toggleClass('toolbar-fixed', isViewportOverflowConstrained || this.model.get('isFixed')).toggleClass('toolbar-tray-open', !!this.model.get('activeTray')).css('padding-top', this.model.get('offsets').top); } }); - -}(jQuery, Drupal, Backbone)); +})(jQuery, Drupal, Backbone); \ No newline at end of file diff --git a/core/modules/toolbar/js/views/MenuVisualView.es6.js b/core/modules/toolbar/js/views/MenuVisualView.es6.js new file mode 100644 index 000000000000..92dea215bf2c --- /dev/null +++ b/core/modules/toolbar/js/views/MenuVisualView.es6.js @@ -0,0 +1,46 @@ +/** + * @file + * A Backbone view for the collapsible menus. + */ + +(function ($, Backbone, Drupal) { + + 'use strict'; + + Drupal.toolbar.MenuVisualView = Backbone.View.extend(/** @lends Drupal.toolbar.MenuVisualView# */{ + + /** + * Backbone View for collapsible menus. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.listenTo(this.model, 'change:subtrees', this.render); + }, + + /** + * @inheritdoc + */ + render: function () { + var subtrees = this.model.get('subtrees'); + // Add subtrees. + for (var id in subtrees) { + if (subtrees.hasOwnProperty(id)) { + this.$el + .find('#toolbar-link-' + id) + .once('toolbar-subtrees') + .after(subtrees[id]); + } + } + // Render the main menu as a nested, collapsible accordion. + if ('drupalToolbarMenu' in $.fn) { + this.$el + .children('.toolbar-menu') + .drupalToolbarMenu(); + } + } + }); + +}(jQuery, Backbone, Drupal)); diff --git a/core/modules/toolbar/js/views/MenuVisualView.js b/core/modules/toolbar/js/views/MenuVisualView.js index 92dea215bf2c..dd9f2877a5b4 100644 --- a/core/modules/toolbar/js/views/MenuVisualView.js +++ b/core/modules/toolbar/js/views/MenuVisualView.js @@ -1,46 +1,32 @@ /** - * @file - * A Backbone view for the collapsible menus. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/views/MenuVisualView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Backbone, Drupal) { 'use strict'; - Drupal.toolbar.MenuVisualView = Backbone.View.extend(/** @lends Drupal.toolbar.MenuVisualView# */{ - - /** - * Backbone View for collapsible menus. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + Drupal.toolbar.MenuVisualView = Backbone.View.extend({ + initialize: function initialize() { this.listenTo(this.model, 'change:subtrees', this.render); }, - /** - * @inheritdoc - */ - render: function () { + render: function render() { var subtrees = this.model.get('subtrees'); - // Add subtrees. + for (var id in subtrees) { if (subtrees.hasOwnProperty(id)) { - this.$el - .find('#toolbar-link-' + id) - .once('toolbar-subtrees') - .after(subtrees[id]); + this.$el.find('#toolbar-link-' + id).once('toolbar-subtrees').after(subtrees[id]); } } - // Render the main menu as a nested, collapsible accordion. + if ('drupalToolbarMenu' in $.fn) { - this.$el - .children('.toolbar-menu') - .drupalToolbarMenu(); + this.$el.children('.toolbar-menu').drupalToolbarMenu(); } } }); - -}(jQuery, Backbone, Drupal)); +})(jQuery, Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/toolbar/js/views/ToolbarAuralView.es6.js b/core/modules/toolbar/js/views/ToolbarAuralView.es6.js new file mode 100644 index 000000000000..3cc9adaf4c96 --- /dev/null +++ b/core/modules/toolbar/js/views/ToolbarAuralView.es6.js @@ -0,0 +1,70 @@ +/** + * @file + * A Backbone view for the aural feedback of the toolbar. + */ + +(function (Backbone, Drupal) { + + 'use strict'; + + Drupal.toolbar.ToolbarAuralView = Backbone.View.extend(/** @lends Drupal.toolbar.ToolbarAuralView# */{ + + /** + * Backbone view for the aural feedback of the toolbar. + * + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * Options for the view. + * @param {object} options.strings + * Various strings to use in the view. + */ + initialize: function (options) { + this.strings = options.strings; + + this.listenTo(this.model, 'change:orientation', this.onOrientationChange); + this.listenTo(this.model, 'change:activeTray', this.onActiveTrayChange); + }, + + /** + * Announces an orientation change. + * + * @param {Drupal.toolbar.ToolbarModel} model + * The toolbar model in question. + * @param {string} orientation + * The new value of the orientation attribute in the model. + */ + onOrientationChange: function (model, orientation) { + Drupal.announce(Drupal.t('Tray orientation changed to @orientation.', { + '@orientation': orientation + })); + }, + + /** + * Announces a changed active tray. + * + * @param {Drupal.toolbar.ToolbarModel} model + * The toolbar model in question. + * @param {HTMLElement} tray + * The new value of the tray attribute in the model. + */ + onActiveTrayChange: function (model, tray) { + var relevantTray = (tray === null) ? model.previous('activeTray') : tray; + var action = (tray === null) ? Drupal.t('closed') : Drupal.t('opened'); + var trayNameElement = relevantTray.querySelector('.toolbar-tray-name'); + var text; + if (trayNameElement !== null) { + text = Drupal.t('Tray "@tray" @action.', { + '@tray': trayNameElement.textContent, '@action': action + }); + } + else { + text = Drupal.t('Tray @action.', {'@action': action}); + } + Drupal.announce(text); + } + }); + +}(Backbone, Drupal)); diff --git a/core/modules/toolbar/js/views/ToolbarAuralView.js b/core/modules/toolbar/js/views/ToolbarAuralView.js index 3cc9adaf4c96..59c2434d9c62 100644 --- a/core/modules/toolbar/js/views/ToolbarAuralView.js +++ b/core/modules/toolbar/js/views/ToolbarAuralView.js @@ -1,70 +1,42 @@ /** - * @file - * A Backbone view for the aural feedback of the toolbar. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/views/ToolbarAuralView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function (Backbone, Drupal) { 'use strict'; - Drupal.toolbar.ToolbarAuralView = Backbone.View.extend(/** @lends Drupal.toolbar.ToolbarAuralView# */{ - - /** - * Backbone view for the aural feedback of the toolbar. - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options for the view. - * @param {object} options.strings - * Various strings to use in the view. - */ - initialize: function (options) { + Drupal.toolbar.ToolbarAuralView = Backbone.View.extend({ + initialize: function initialize(options) { this.strings = options.strings; this.listenTo(this.model, 'change:orientation', this.onOrientationChange); this.listenTo(this.model, 'change:activeTray', this.onActiveTrayChange); }, - /** - * Announces an orientation change. - * - * @param {Drupal.toolbar.ToolbarModel} model - * The toolbar model in question. - * @param {string} orientation - * The new value of the orientation attribute in the model. - */ - onOrientationChange: function (model, orientation) { + onOrientationChange: function onOrientationChange(model, orientation) { Drupal.announce(Drupal.t('Tray orientation changed to @orientation.', { '@orientation': orientation })); }, - /** - * Announces a changed active tray. - * - * @param {Drupal.toolbar.ToolbarModel} model - * The toolbar model in question. - * @param {HTMLElement} tray - * The new value of the tray attribute in the model. - */ - onActiveTrayChange: function (model, tray) { - var relevantTray = (tray === null) ? model.previous('activeTray') : tray; - var action = (tray === null) ? Drupal.t('closed') : Drupal.t('opened'); + onActiveTrayChange: function onActiveTrayChange(model, tray) { + var relevantTray = tray === null ? model.previous('activeTray') : tray; + var action = tray === null ? Drupal.t('closed') : Drupal.t('opened'); var trayNameElement = relevantTray.querySelector('.toolbar-tray-name'); var text; if (trayNameElement !== null) { text = Drupal.t('Tray "@tray" @action.', { '@tray': trayNameElement.textContent, '@action': action }); - } - else { - text = Drupal.t('Tray @action.', {'@action': action}); + } else { + text = Drupal.t('Tray @action.', { '@action': action }); } Drupal.announce(text); } }); - -}(Backbone, Drupal)); +})(Backbone, Drupal); \ No newline at end of file diff --git a/core/modules/toolbar/js/views/ToolbarVisualView.es6.js b/core/modules/toolbar/js/views/ToolbarVisualView.es6.js new file mode 100644 index 000000000000..f26c98c4d933 --- /dev/null +++ b/core/modules/toolbar/js/views/ToolbarVisualView.es6.js @@ -0,0 +1,305 @@ +/** + * @file + * A Backbone view for the toolbar element. Listens to mouse & touch. + */ + +(function ($, Drupal, drupalSettings, Backbone) { + + 'use strict'; + + Drupal.toolbar.ToolbarVisualView = Backbone.View.extend(/** @lends Drupal.toolbar.ToolbarVisualView# */{ + + /** + * Event map for the `ToolbarVisualView`. + * + * @return {object} + * A map of events. + */ + events: function () { + // Prevents delay and simulated mouse events. + var touchEndToClick = function (event) { + event.preventDefault(); + event.target.click(); + }; + + return { + 'click .toolbar-bar .toolbar-tab .trigger': 'onTabClick', + 'click .toolbar-toggle-orientation button': 'onOrientationToggleClick', + 'touchend .toolbar-bar .toolbar-tab .trigger': touchEndToClick, + 'touchend .toolbar-toggle-orientation button': touchEndToClick + }; + }, + + /** + * Backbone view for the toolbar element. Listens to mouse & touch. + * + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * Options for the view object. + * @param {object} options.strings + * Various strings to use in the view. + */ + initialize: function (options) { + this.strings = options.strings; + + this.listenTo(this.model, 'change:activeTab change:orientation change:isOriented change:isTrayToggleVisible', this.render); + this.listenTo(this.model, 'change:mqMatches', this.onMediaQueryChange); + this.listenTo(this.model, 'change:offsets', this.adjustPlacement); + + // Add the tray orientation toggles. + this.$el + .find('.toolbar-tray .toolbar-lining') + .append(Drupal.theme('toolbarOrientationToggle')); + + // Trigger an activeTab change so that listening scripts can respond on + // page load. This will call render. + this.model.trigger('change:activeTab'); + }, + + /** + * @inheritdoc + * + * @return {Drupal.toolbar.ToolbarVisualView} + * The `ToolbarVisualView` instance. + */ + render: function () { + this.updateTabs(); + this.updateTrayOrientation(); + this.updateBarAttributes(); + // Load the subtrees if the orientation of the toolbar is changed to + // vertical. This condition responds to the case that the toolbar switches + // from horizontal to vertical orientation. The toolbar starts in a + // vertical orientation by default and then switches to horizontal during + // initialization if the media query conditions are met. Simply checking + // that the orientation is vertical here would result in the subtrees + // always being loaded, even when the toolbar initialization ultimately + // results in a horizontal orientation. + // + // @see Drupal.behaviors.toolbar.attach() where admin menu subtrees + // loading is invoked during initialization after media query conditions + // have been processed. + if (this.model.changed.orientation === 'vertical' || this.model.changed.activeTab) { + this.loadSubtrees(); + } + // Trigger a recalculation of viewport displacing elements. Use setTimeout + // to ensure this recalculation happens after changes to visual elements + // have processed. + window.setTimeout(function () { + Drupal.displace(true); + }, 0); + return this; + }, + + /** + * Responds to a toolbar tab click. + * + * @param {jQuery.Event} event + * The event triggered. + */ + onTabClick: function (event) { + // If this tab has a tray associated with it, it is considered an + // activatable tab. + if (event.target.hasAttribute('data-toolbar-tray')) { + var activeTab = this.model.get('activeTab'); + var clickedTab = event.target; + + // Set the event target as the active item if it is not already. + this.model.set('activeTab', (!activeTab || clickedTab !== activeTab) ? clickedTab : null); + + event.preventDefault(); + event.stopPropagation(); + } + }, + + /** + * Toggles the orientation of a toolbar tray. + * + * @param {jQuery.Event} event + * The event triggered. + */ + onOrientationToggleClick: function (event) { + var orientation = this.model.get('orientation'); + // Determine the toggle-to orientation. + var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical'; + var locked = antiOrientation === 'vertical'; + // Remember the locked state. + if (locked) { + localStorage.setItem('Drupal.toolbar.trayVerticalLocked', 'true'); + } + else { + localStorage.removeItem('Drupal.toolbar.trayVerticalLocked'); + } + // Update the model. + this.model.set({ + locked: locked, + orientation: antiOrientation + }, { + validate: true, + override: true + }); + + event.preventDefault(); + event.stopPropagation(); + }, + + /** + * Updates the display of the tabs: toggles a tab and the associated tray. + */ + updateTabs: function () { + var $tab = $(this.model.get('activeTab')); + // Deactivate the previous tab. + $(this.model.previous('activeTab')) + .removeClass('is-active') + .prop('aria-pressed', false); + // Deactivate the previous tray. + $(this.model.previous('activeTray')) + .removeClass('is-active'); + + // Activate the selected tab. + if ($tab.length > 0) { + $tab + .addClass('is-active') + // Mark the tab as pressed. + .prop('aria-pressed', true); + var name = $tab.attr('data-toolbar-tray'); + // Store the active tab name or remove the setting. + var id = $tab.get(0).id; + if (id) { + localStorage.setItem('Drupal.toolbar.activeTabID', JSON.stringify(id)); + } + // Activate the associated tray. + var $tray = this.$el.find('[data-toolbar-tray="' + name + '"].toolbar-tray'); + if ($tray.length) { + $tray.addClass('is-active'); + this.model.set('activeTray', $tray.get(0)); + } + else { + // There is no active tray. + this.model.set('activeTray', null); + } + } + else { + // There is no active tray. + this.model.set('activeTray', null); + localStorage.removeItem('Drupal.toolbar.activeTabID'); + } + }, + + /** + * Update the attributes of the toolbar bar element. + */ + updateBarAttributes: function () { + var isOriented = this.model.get('isOriented'); + if (isOriented) { + this.$el.find('.toolbar-bar').attr('data-offset-top', ''); + } + else { + this.$el.find('.toolbar-bar').removeAttr('data-offset-top'); + } + // Toggle between a basic vertical view and a more sophisticated + // horizontal and vertical display of the toolbar bar and trays. + this.$el.toggleClass('toolbar-oriented', isOriented); + }, + + /** + * Updates the orientation of the active tray if necessary. + */ + updateTrayOrientation: function () { + var orientation = this.model.get('orientation'); + // The antiOrientation is used to render the view of action buttons like + // the tray orientation toggle. + var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical'; + // Update the orientation of the trays. + var $trays = this.$el.find('.toolbar-tray') + .removeClass('toolbar-tray-horizontal toolbar-tray-vertical') + .addClass('toolbar-tray-' + orientation); + + // Update the tray orientation toggle button. + var iconClass = 'toolbar-icon-toggle-' + orientation; + var iconAntiClass = 'toolbar-icon-toggle-' + antiOrientation; + var $orientationToggle = this.$el.find('.toolbar-toggle-orientation') + .toggle(this.model.get('isTrayToggleVisible')); + $orientationToggle.find('button') + .val(antiOrientation) + .attr('title', this.strings[antiOrientation]) + .text(this.strings[antiOrientation]) + .removeClass(iconClass) + .addClass(iconAntiClass); + + // Update data offset attributes for the trays. + var dir = document.documentElement.dir; + var edge = (dir === 'rtl') ? 'right' : 'left'; + // Remove data-offset attributes from the trays so they can be refreshed. + $trays.removeAttr('data-offset-left data-offset-right data-offset-top'); + // If an active vertical tray exists, mark it as an offset element. + $trays.filter('.toolbar-tray-vertical.is-active').attr('data-offset-' + edge, ''); + // If an active horizontal tray exists, mark it as an offset element. + $trays.filter('.toolbar-tray-horizontal.is-active').attr('data-offset-top', ''); + }, + + /** + * Sets the tops of the trays so that they align with the bottom of the bar. + */ + adjustPlacement: function () { + var $trays = this.$el.find('.toolbar-tray'); + if (!this.model.get('isOriented')) { + $trays.css('margin-top', 0); + $trays.removeClass('toolbar-tray-horizontal').addClass('toolbar-tray-vertical'); + } + else { + // The toolbar container is invisible. Its placement is used to + // determine the container for the trays. + $trays.css('margin-top', this.$el.find('.toolbar-bar').outerHeight()); + } + }, + + /** + * Calls the endpoint URI that builds an AJAX command with the rendered + * subtrees. + * + * The rendered admin menu subtrees HTML is cached on the client in + * localStorage until the cache of the admin menu subtrees on the server- + * side is invalidated. The subtreesHash is stored in localStorage as well + * and compared to the subtreesHash in drupalSettings to determine when the + * admin menu subtrees cache has been invalidated. + */ + loadSubtrees: function () { + var $activeTab = $(this.model.get('activeTab')); + var orientation = this.model.get('orientation'); + // Only load and render the admin menu subtrees if: + // (1) They have not been loaded yet. + // (2) The active tab is the administration menu tab, indicated by the + // presence of the data-drupal-subtrees attribute. + // (3) The orientation of the tray is vertical. + if (!this.model.get('areSubtreesLoaded') && typeof $activeTab.data('drupal-subtrees') !== 'undefined' && orientation === 'vertical') { + var subtreesHash = drupalSettings.toolbar.subtreesHash; + var theme = drupalSettings.ajaxPageState.theme; + var endpoint = Drupal.url('toolbar/subtrees/' + subtreesHash); + var cachedSubtreesHash = localStorage.getItem('Drupal.toolbar.subtreesHash.' + theme); + var cachedSubtrees = JSON.parse(localStorage.getItem('Drupal.toolbar.subtrees.' + theme)); + var isVertical = this.model.get('orientation') === 'vertical'; + // If we have the subtrees in localStorage and the subtree hash has not + // changed, then use the cached data. + if (isVertical && subtreesHash === cachedSubtreesHash && cachedSubtrees) { + Drupal.toolbar.setSubtrees.resolve(cachedSubtrees); + } + // Only make the call to get the subtrees if the orientation of the + // toolbar is vertical. + else if (isVertical) { + // Remove the cached menu information. + localStorage.removeItem('Drupal.toolbar.subtreesHash.' + theme); + localStorage.removeItem('Drupal.toolbar.subtrees.' + theme); + // The AJAX response's command will trigger the resolve method of the + // Drupal.toolbar.setSubtrees Promise. + Drupal.ajax({url: endpoint}).execute(); + // Cache the hash for the subtrees locally. + localStorage.setItem('Drupal.toolbar.subtreesHash.' + theme, subtreesHash); + } + } + } + }); + +}(jQuery, Drupal, drupalSettings, Backbone)); diff --git a/core/modules/toolbar/js/views/ToolbarVisualView.js b/core/modules/toolbar/js/views/ToolbarVisualView.js index f26c98c4d933..330830bd50cd 100644 --- a/core/modules/toolbar/js/views/ToolbarVisualView.js +++ b/core/modules/toolbar/js/views/ToolbarVisualView.js @@ -1,23 +1,18 @@ /** - * @file - * A Backbone view for the toolbar element. Listens to mouse & touch. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/toolbar/js/views/ToolbarVisualView.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings, Backbone) { 'use strict'; - Drupal.toolbar.ToolbarVisualView = Backbone.View.extend(/** @lends Drupal.toolbar.ToolbarVisualView# */{ - - /** - * Event map for the `ToolbarVisualView`. - * - * @return {object} - * A map of events. - */ - events: function () { - // Prevents delay and simulated mouse events. - var touchEndToClick = function (event) { + Drupal.toolbar.ToolbarVisualView = Backbone.View.extend({ + events: function events() { + var touchEndToClick = function touchEndToClick(event) { event.preventDefault(); event.target.click(); }; @@ -30,109 +25,57 @@ }; }, - /** - * Backbone view for the toolbar element. Listens to mouse & touch. - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options for the view object. - * @param {object} options.strings - * Various strings to use in the view. - */ - initialize: function (options) { + initialize: function initialize(options) { this.strings = options.strings; this.listenTo(this.model, 'change:activeTab change:orientation change:isOriented change:isTrayToggleVisible', this.render); this.listenTo(this.model, 'change:mqMatches', this.onMediaQueryChange); this.listenTo(this.model, 'change:offsets', this.adjustPlacement); - // Add the tray orientation toggles. - this.$el - .find('.toolbar-tray .toolbar-lining') - .append(Drupal.theme('toolbarOrientationToggle')); + this.$el.find('.toolbar-tray .toolbar-lining').append(Drupal.theme('toolbarOrientationToggle')); - // Trigger an activeTab change so that listening scripts can respond on - // page load. This will call render. this.model.trigger('change:activeTab'); }, - /** - * @inheritdoc - * - * @return {Drupal.toolbar.ToolbarVisualView} - * The `ToolbarVisualView` instance. - */ - render: function () { + render: function render() { this.updateTabs(); this.updateTrayOrientation(); this.updateBarAttributes(); - // Load the subtrees if the orientation of the toolbar is changed to - // vertical. This condition responds to the case that the toolbar switches - // from horizontal to vertical orientation. The toolbar starts in a - // vertical orientation by default and then switches to horizontal during - // initialization if the media query conditions are met. Simply checking - // that the orientation is vertical here would result in the subtrees - // always being loaded, even when the toolbar initialization ultimately - // results in a horizontal orientation. - // - // @see Drupal.behaviors.toolbar.attach() where admin menu subtrees - // loading is invoked during initialization after media query conditions - // have been processed. + if (this.model.changed.orientation === 'vertical' || this.model.changed.activeTab) { this.loadSubtrees(); } - // Trigger a recalculation of viewport displacing elements. Use setTimeout - // to ensure this recalculation happens after changes to visual elements - // have processed. + window.setTimeout(function () { Drupal.displace(true); }, 0); return this; }, - /** - * Responds to a toolbar tab click. - * - * @param {jQuery.Event} event - * The event triggered. - */ - onTabClick: function (event) { - // If this tab has a tray associated with it, it is considered an - // activatable tab. + onTabClick: function onTabClick(event) { if (event.target.hasAttribute('data-toolbar-tray')) { var activeTab = this.model.get('activeTab'); var clickedTab = event.target; - // Set the event target as the active item if it is not already. - this.model.set('activeTab', (!activeTab || clickedTab !== activeTab) ? clickedTab : null); + this.model.set('activeTab', !activeTab || clickedTab !== activeTab ? clickedTab : null); event.preventDefault(); event.stopPropagation(); } }, - /** - * Toggles the orientation of a toolbar tray. - * - * @param {jQuery.Event} event - * The event triggered. - */ - onOrientationToggleClick: function (event) { + onOrientationToggleClick: function onOrientationToggleClick(event) { var orientation = this.model.get('orientation'); - // Determine the toggle-to orientation. - var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical'; + + var antiOrientation = orientation === 'vertical' ? 'horizontal' : 'vertical'; var locked = antiOrientation === 'vertical'; - // Remember the locked state. + if (locked) { localStorage.setItem('Drupal.toolbar.trayVerticalLocked', 'true'); - } - else { + } else { localStorage.removeItem('Drupal.toolbar.trayVerticalLocked'); } - // Update the model. + this.model.set({ locked: locked, orientation: antiOrientation @@ -145,135 +88,82 @@ event.stopPropagation(); }, - /** - * Updates the display of the tabs: toggles a tab and the associated tray. - */ - updateTabs: function () { + updateTabs: function updateTabs() { var $tab = $(this.model.get('activeTab')); - // Deactivate the previous tab. - $(this.model.previous('activeTab')) - .removeClass('is-active') - .prop('aria-pressed', false); - // Deactivate the previous tray. - $(this.model.previous('activeTray')) - .removeClass('is-active'); - - // Activate the selected tab. + + $(this.model.previous('activeTab')).removeClass('is-active').prop('aria-pressed', false); + + $(this.model.previous('activeTray')).removeClass('is-active'); + if ($tab.length > 0) { - $tab - .addClass('is-active') - // Mark the tab as pressed. - .prop('aria-pressed', true); + $tab.addClass('is-active').prop('aria-pressed', true); var name = $tab.attr('data-toolbar-tray'); - // Store the active tab name or remove the setting. + var id = $tab.get(0).id; if (id) { localStorage.setItem('Drupal.toolbar.activeTabID', JSON.stringify(id)); } - // Activate the associated tray. + var $tray = this.$el.find('[data-toolbar-tray="' + name + '"].toolbar-tray'); if ($tray.length) { $tray.addClass('is-active'); this.model.set('activeTray', $tray.get(0)); - } - else { - // There is no active tray. + } else { this.model.set('activeTray', null); } - } - else { - // There is no active tray. + } else { this.model.set('activeTray', null); localStorage.removeItem('Drupal.toolbar.activeTabID'); } }, - /** - * Update the attributes of the toolbar bar element. - */ - updateBarAttributes: function () { + updateBarAttributes: function updateBarAttributes() { var isOriented = this.model.get('isOriented'); if (isOriented) { this.$el.find('.toolbar-bar').attr('data-offset-top', ''); - } - else { + } else { this.$el.find('.toolbar-bar').removeAttr('data-offset-top'); } - // Toggle between a basic vertical view and a more sophisticated - // horizontal and vertical display of the toolbar bar and trays. + this.$el.toggleClass('toolbar-oriented', isOriented); }, - /** - * Updates the orientation of the active tray if necessary. - */ - updateTrayOrientation: function () { + updateTrayOrientation: function updateTrayOrientation() { var orientation = this.model.get('orientation'); - // The antiOrientation is used to render the view of action buttons like - // the tray orientation toggle. - var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical'; - // Update the orientation of the trays. - var $trays = this.$el.find('.toolbar-tray') - .removeClass('toolbar-tray-horizontal toolbar-tray-vertical') - .addClass('toolbar-tray-' + orientation); - - // Update the tray orientation toggle button. + + var antiOrientation = orientation === 'vertical' ? 'horizontal' : 'vertical'; + + var $trays = this.$el.find('.toolbar-tray').removeClass('toolbar-tray-horizontal toolbar-tray-vertical').addClass('toolbar-tray-' + orientation); + var iconClass = 'toolbar-icon-toggle-' + orientation; var iconAntiClass = 'toolbar-icon-toggle-' + antiOrientation; - var $orientationToggle = this.$el.find('.toolbar-toggle-orientation') - .toggle(this.model.get('isTrayToggleVisible')); - $orientationToggle.find('button') - .val(antiOrientation) - .attr('title', this.strings[antiOrientation]) - .text(this.strings[antiOrientation]) - .removeClass(iconClass) - .addClass(iconAntiClass); - - // Update data offset attributes for the trays. + var $orientationToggle = this.$el.find('.toolbar-toggle-orientation').toggle(this.model.get('isTrayToggleVisible')); + $orientationToggle.find('button').val(antiOrientation).attr('title', this.strings[antiOrientation]).text(this.strings[antiOrientation]).removeClass(iconClass).addClass(iconAntiClass); + var dir = document.documentElement.dir; - var edge = (dir === 'rtl') ? 'right' : 'left'; - // Remove data-offset attributes from the trays so they can be refreshed. + var edge = dir === 'rtl' ? 'right' : 'left'; + $trays.removeAttr('data-offset-left data-offset-right data-offset-top'); - // If an active vertical tray exists, mark it as an offset element. + $trays.filter('.toolbar-tray-vertical.is-active').attr('data-offset-' + edge, ''); - // If an active horizontal tray exists, mark it as an offset element. + $trays.filter('.toolbar-tray-horizontal.is-active').attr('data-offset-top', ''); }, - /** - * Sets the tops of the trays so that they align with the bottom of the bar. - */ - adjustPlacement: function () { + adjustPlacement: function adjustPlacement() { var $trays = this.$el.find('.toolbar-tray'); if (!this.model.get('isOriented')) { $trays.css('margin-top', 0); $trays.removeClass('toolbar-tray-horizontal').addClass('toolbar-tray-vertical'); - } - else { - // The toolbar container is invisible. Its placement is used to - // determine the container for the trays. + } else { $trays.css('margin-top', this.$el.find('.toolbar-bar').outerHeight()); } }, - /** - * Calls the endpoint URI that builds an AJAX command with the rendered - * subtrees. - * - * The rendered admin menu subtrees HTML is cached on the client in - * localStorage until the cache of the admin menu subtrees on the server- - * side is invalidated. The subtreesHash is stored in localStorage as well - * and compared to the subtreesHash in drupalSettings to determine when the - * admin menu subtrees cache has been invalidated. - */ - loadSubtrees: function () { + loadSubtrees: function loadSubtrees() { var $activeTab = $(this.model.get('activeTab')); var orientation = this.model.get('orientation'); - // Only load and render the admin menu subtrees if: - // (1) They have not been loaded yet. - // (2) The active tab is the administration menu tab, indicated by the - // presence of the data-drupal-subtrees attribute. - // (3) The orientation of the tray is vertical. + if (!this.model.get('areSubtreesLoaded') && typeof $activeTab.data('drupal-subtrees') !== 'undefined' && orientation === 'vertical') { var subtreesHash = drupalSettings.toolbar.subtreesHash; var theme = drupalSettings.ajaxPageState.theme; @@ -281,25 +171,18 @@ var cachedSubtreesHash = localStorage.getItem('Drupal.toolbar.subtreesHash.' + theme); var cachedSubtrees = JSON.parse(localStorage.getItem('Drupal.toolbar.subtrees.' + theme)); var isVertical = this.model.get('orientation') === 'vertical'; - // If we have the subtrees in localStorage and the subtree hash has not - // changed, then use the cached data. + if (isVertical && subtreesHash === cachedSubtreesHash && cachedSubtrees) { Drupal.toolbar.setSubtrees.resolve(cachedSubtrees); - } - // Only make the call to get the subtrees if the orientation of the - // toolbar is vertical. - else if (isVertical) { - // Remove the cached menu information. - localStorage.removeItem('Drupal.toolbar.subtreesHash.' + theme); - localStorage.removeItem('Drupal.toolbar.subtrees.' + theme); - // The AJAX response's command will trigger the resolve method of the - // Drupal.toolbar.setSubtrees Promise. - Drupal.ajax({url: endpoint}).execute(); - // Cache the hash for the subtrees locally. - localStorage.setItem('Drupal.toolbar.subtreesHash.' + theme, subtreesHash); - } + } else if (isVertical) { + localStorage.removeItem('Drupal.toolbar.subtreesHash.' + theme); + localStorage.removeItem('Drupal.toolbar.subtrees.' + theme); + + Drupal.ajax({ url: endpoint }).execute(); + + localStorage.setItem('Drupal.toolbar.subtreesHash.' + theme, subtreesHash); + } } } }); - -}(jQuery, Drupal, drupalSettings, Backbone)); +})(jQuery, Drupal, drupalSettings, Backbone); \ No newline at end of file diff --git a/core/modules/tour/js/tour.es6.js b/core/modules/tour/js/tour.es6.js new file mode 100644 index 000000000000..2c7050f75fd0 --- /dev/null +++ b/core/modules/tour/js/tour.es6.js @@ -0,0 +1,270 @@ +/** + * @file + * Attaches behaviors for the Tour module's toolbar tab. + */ + +(function ($, Backbone, Drupal, document) { + + 'use strict'; + + var queryString = decodeURI(window.location.search); + + /** + * Attaches the tour's toolbar tab behavior. + * + * It uses the query string for: + * - tour: When ?tour=1 is present, the tour will start automatically after + * the page has loaded. + * - tips: Pass ?tips=class in the url to filter the available tips to the + * subset which match the given class. + * + * @example + * http://example.com/foo?tour=1&tips=bar + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attach tour functionality on `tour` events. + */ + Drupal.behaviors.tour = { + attach: function (context) { + $('body').once('tour').each(function () { + var model = new Drupal.tour.models.StateModel(); + new Drupal.tour.views.ToggleTourView({ + el: $(context).find('#toolbar-tab-tour'), + model: model + }); + + model + // Allow other scripts to respond to tour events. + .on('change:isActive', function (model, isActive) { + $(document).trigger((isActive) ? 'drupalTourStarted' : 'drupalTourStopped'); + }) + // Initialization: check whether a tour is available on the current + // page. + .set('tour', $(context).find('ol#tour')); + + // Start the tour immediately if toggled via query string. + if (/tour=?/i.test(queryString)) { + model.set('isActive', true); + } + }); + } + }; + + /** + * @namespace + */ + Drupal.tour = Drupal.tour || { + + /** + * @namespace Drupal.tour.models + */ + models: {}, + + /** + * @namespace Drupal.tour.views + */ + views: {} + }; + + /** + * Backbone Model for tours. + * + * @constructor + * + * @augments Backbone.Model + */ + Drupal.tour.models.StateModel = Backbone.Model.extend(/** @lends Drupal.tour.models.StateModel# */{ + + /** + * @type {object} + */ + defaults: /** @lends Drupal.tour.models.StateModel# */{ + + /** + * Indicates whether the Drupal root window has a tour. + * + * @type {Array} + */ + tour: [], + + /** + * Indicates whether the tour is currently running. + * + * @type {bool} + */ + isActive: false, + + /** + * Indicates which tour is the active one (necessary to cleanly stop). + * + * @type {Array} + */ + activeTour: [] + } + }); + + Drupal.tour.views.ToggleTourView = Backbone.View.extend(/** @lends Drupal.tour.views.ToggleTourView# */{ + + /** + * @type {object} + */ + events: {click: 'onClick'}, + + /** + * Handles edit mode toggle interactions. + * + * @constructs + * + * @augments Backbone.View + */ + initialize: function () { + this.listenTo(this.model, 'change:tour change:isActive', this.render); + this.listenTo(this.model, 'change:isActive', this.toggleTour); + }, + + /** + * @inheritdoc + * + * @return {Drupal.tour.views.ToggleTourView} + * The `ToggleTourView` view. + */ + render: function () { + // Render the visibility. + this.$el.toggleClass('hidden', this._getTour().length === 0); + // Render the state. + var isActive = this.model.get('isActive'); + this.$el.find('button') + .toggleClass('is-active', isActive) + .prop('aria-pressed', isActive); + return this; + }, + + /** + * Model change handler; starts or stops the tour. + */ + toggleTour: function () { + if (this.model.get('isActive')) { + var $tour = this._getTour(); + this._removeIrrelevantTourItems($tour, this._getDocument()); + var that = this; + if ($tour.find('li').length) { + $tour.joyride({ + autoStart: true, + postRideCallback: function () { that.model.set('isActive', false); }, + // HTML segments for tip layout. + template: { + link: '<a href=\"#close\" class=\"joyride-close-tip\">×</a>', + button: '<a href=\"#\" class=\"button button--primary joyride-next-tip\"></a>' + } + }); + this.model.set({isActive: true, activeTour: $tour}); + } + } + else { + this.model.get('activeTour').joyride('destroy'); + this.model.set({isActive: false, activeTour: []}); + } + }, + + /** + * Toolbar tab click event handler; toggles isActive. + * + * @param {jQuery.Event} event + * The click event. + */ + onClick: function (event) { + this.model.set('isActive', !this.model.get('isActive')); + event.preventDefault(); + event.stopPropagation(); + }, + + /** + * Gets the tour. + * + * @return {jQuery} + * A jQuery element pointing to a `<ol>` containing tour items. + */ + _getTour: function () { + return this.model.get('tour'); + }, + + /** + * Gets the relevant document as a jQuery element. + * + * @return {jQuery} + * A jQuery element pointing to the document within which a tour would be + * started given the current state. + */ + _getDocument: function () { + return $(document); + }, + + /** + * Removes tour items for elements that don't have matching page elements. + * + * Or that are explicitly filtered out via the 'tips' query string. + * + * @example + * <caption>This will filter out tips that do not have a matching + * page element or don't have the "bar" class.</caption> + * http://example.com/foo?tips=bar + * + * @param {jQuery} $tour + * A jQuery element pointing to a `<ol>` containing tour items. + * @param {jQuery} $document + * A jQuery element pointing to the document within which the elements + * should be sought. + * + * @see Drupal.tour.views.ToggleTourView#_getDocument + */ + _removeIrrelevantTourItems: function ($tour, $document) { + var removals = false; + var tips = /tips=([^&]+)/.exec(queryString); + $tour + .find('li') + .each(function () { + var $this = $(this); + var itemId = $this.attr('data-id'); + var itemClass = $this.attr('data-class'); + // If the query parameter 'tips' is set, remove all tips that don't + // have the matching class. + if (tips && !$(this).hasClass(tips[1])) { + removals = true; + $this.remove(); + return; + } + // Remove tip from the DOM if there is no corresponding page element. + if ((!itemId && !itemClass) || + (itemId && $document.find('#' + itemId).length) || + (itemClass && $document.find('.' + itemClass).length)) { + return; + } + removals = true; + $this.remove(); + }); + + // If there were removals, we'll have to do some clean-up. + if (removals) { + var total = $tour.find('li').length; + if (!total) { + this.model.set({tour: []}); + } + + $tour + .find('li') + // Rebuild the progress data. + .each(function (index) { + var progress = Drupal.t('!tour_item of !total', {'!tour_item': index + 1, '!total': total}); + $(this).find('.tour-progress').text(progress); + }) + // Update the last item to have "End tour" as the button. + .eq(-1) + .attr('data-text', Drupal.t('End tour')); + } + } + + }); + +})(jQuery, Backbone, Drupal, document); diff --git a/core/modules/tour/js/tour.js b/core/modules/tour/js/tour.js index 2c7050f75fd0..59c0278f54b4 100644 --- a/core/modules/tour/js/tour.js +++ b/core/modules/tour/js/tour.js @@ -1,7 +1,10 @@ /** - * @file - * Attaches behaviors for the Tour module's toolbar tab. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/tour/js/tour.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Backbone, Drupal, document) { @@ -9,25 +12,8 @@ var queryString = decodeURI(window.location.search); - /** - * Attaches the tour's toolbar tab behavior. - * - * It uses the query string for: - * - tour: When ?tour=1 is present, the tour will start automatically after - * the page has loaded. - * - tips: Pass ?tips=class in the url to filter the available tips to the - * subset which match the given class. - * - * @example - * http://example.com/foo?tour=1&tips=bar - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attach tour functionality on `tour` events. - */ Drupal.behaviors.tour = { - attach: function (context) { + attach: function attach(context) { $('body').once('tour').each(function () { var model = new Drupal.tour.models.StateModel(); new Drupal.tour.views.ToggleTourView({ @@ -35,16 +21,10 @@ model: model }); - model - // Allow other scripts to respond to tour events. - .on('change:isActive', function (model, isActive) { - $(document).trigger((isActive) ? 'drupalTourStarted' : 'drupalTourStopped'); - }) - // Initialization: check whether a tour is available on the current - // page. - .set('tour', $(context).find('ol#tour')); + model.on('change:isActive', function (model, isActive) { + $(document).trigger(isActive ? 'drupalTourStarted' : 'drupalTourStopped'); + }).set('tour', $(context).find('ol#tour')); - // Start the tour immediately if toggled via query string. if (/tour=?/i.test(queryString)) { model.set('isActive', true); } @@ -52,99 +32,39 @@ } }; - /** - * @namespace - */ Drupal.tour = Drupal.tour || { - - /** - * @namespace Drupal.tour.models - */ models: {}, - /** - * @namespace Drupal.tour.views - */ views: {} }; - /** - * Backbone Model for tours. - * - * @constructor - * - * @augments Backbone.Model - */ - Drupal.tour.models.StateModel = Backbone.Model.extend(/** @lends Drupal.tour.models.StateModel# */{ - - /** - * @type {object} - */ - defaults: /** @lends Drupal.tour.models.StateModel# */{ - - /** - * Indicates whether the Drupal root window has a tour. - * - * @type {Array} - */ + Drupal.tour.models.StateModel = Backbone.Model.extend({ + defaults: { tour: [], - /** - * Indicates whether the tour is currently running. - * - * @type {bool} - */ isActive: false, - /** - * Indicates which tour is the active one (necessary to cleanly stop). - * - * @type {Array} - */ activeTour: [] } }); - Drupal.tour.views.ToggleTourView = Backbone.View.extend(/** @lends Drupal.tour.views.ToggleTourView# */{ - - /** - * @type {object} - */ - events: {click: 'onClick'}, + Drupal.tour.views.ToggleTourView = Backbone.View.extend({ + events: { click: 'onClick' }, - /** - * Handles edit mode toggle interactions. - * - * @constructs - * - * @augments Backbone.View - */ - initialize: function () { + initialize: function initialize() { this.listenTo(this.model, 'change:tour change:isActive', this.render); this.listenTo(this.model, 'change:isActive', this.toggleTour); }, - /** - * @inheritdoc - * - * @return {Drupal.tour.views.ToggleTourView} - * The `ToggleTourView` view. - */ - render: function () { - // Render the visibility. + render: function render() { this.$el.toggleClass('hidden', this._getTour().length === 0); - // Render the state. + var isActive = this.model.get('isActive'); - this.$el.find('button') - .toggleClass('is-active', isActive) - .prop('aria-pressed', isActive); + this.$el.find('button').toggleClass('is-active', isActive).prop('aria-pressed', isActive); return this; }, - /** - * Model change handler; starts or stops the tour. - */ - toggleTour: function () { + toggleTour: function toggleTour() { if (this.model.get('isActive')) { var $tour = this._getTour(); this._removeIrrelevantTourItems($tour, this._getDocument()); @@ -152,119 +72,70 @@ if ($tour.find('li').length) { $tour.joyride({ autoStart: true, - postRideCallback: function () { that.model.set('isActive', false); }, - // HTML segments for tip layout. + postRideCallback: function postRideCallback() { + that.model.set('isActive', false); + }, + template: { link: '<a href=\"#close\" class=\"joyride-close-tip\">×</a>', button: '<a href=\"#\" class=\"button button--primary joyride-next-tip\"></a>' } }); - this.model.set({isActive: true, activeTour: $tour}); + this.model.set({ isActive: true, activeTour: $tour }); } - } - else { + } else { this.model.get('activeTour').joyride('destroy'); - this.model.set({isActive: false, activeTour: []}); + this.model.set({ isActive: false, activeTour: [] }); } }, - /** - * Toolbar tab click event handler; toggles isActive. - * - * @param {jQuery.Event} event - * The click event. - */ - onClick: function (event) { + onClick: function onClick(event) { this.model.set('isActive', !this.model.get('isActive')); event.preventDefault(); event.stopPropagation(); }, - /** - * Gets the tour. - * - * @return {jQuery} - * A jQuery element pointing to a `<ol>` containing tour items. - */ - _getTour: function () { + _getTour: function _getTour() { return this.model.get('tour'); }, - /** - * Gets the relevant document as a jQuery element. - * - * @return {jQuery} - * A jQuery element pointing to the document within which a tour would be - * started given the current state. - */ - _getDocument: function () { + _getDocument: function _getDocument() { return $(document); }, - /** - * Removes tour items for elements that don't have matching page elements. - * - * Or that are explicitly filtered out via the 'tips' query string. - * - * @example - * <caption>This will filter out tips that do not have a matching - * page element or don't have the "bar" class.</caption> - * http://example.com/foo?tips=bar - * - * @param {jQuery} $tour - * A jQuery element pointing to a `<ol>` containing tour items. - * @param {jQuery} $document - * A jQuery element pointing to the document within which the elements - * should be sought. - * - * @see Drupal.tour.views.ToggleTourView#_getDocument - */ - _removeIrrelevantTourItems: function ($tour, $document) { + _removeIrrelevantTourItems: function _removeIrrelevantTourItems($tour, $document) { var removals = false; var tips = /tips=([^&]+)/.exec(queryString); - $tour - .find('li') - .each(function () { - var $this = $(this); - var itemId = $this.attr('data-id'); - var itemClass = $this.attr('data-class'); - // If the query parameter 'tips' is set, remove all tips that don't - // have the matching class. - if (tips && !$(this).hasClass(tips[1])) { - removals = true; - $this.remove(); - return; - } - // Remove tip from the DOM if there is no corresponding page element. - if ((!itemId && !itemClass) || - (itemId && $document.find('#' + itemId).length) || - (itemClass && $document.find('.' + itemClass).length)) { - return; - } + $tour.find('li').each(function () { + var $this = $(this); + var itemId = $this.attr('data-id'); + var itemClass = $this.attr('data-class'); + + if (tips && !$(this).hasClass(tips[1])) { removals = true; $this.remove(); - }); + return; + } + + if (!itemId && !itemClass || itemId && $document.find('#' + itemId).length || itemClass && $document.find('.' + itemClass).length) { + return; + } + removals = true; + $this.remove(); + }); - // If there were removals, we'll have to do some clean-up. if (removals) { var total = $tour.find('li').length; if (!total) { - this.model.set({tour: []}); + this.model.set({ tour: [] }); } - $tour - .find('li') - // Rebuild the progress data. - .each(function (index) { - var progress = Drupal.t('!tour_item of !total', {'!tour_item': index + 1, '!total': total}); - $(this).find('.tour-progress').text(progress); - }) - // Update the last item to have "End tour" as the button. - .eq(-1) - .attr('data-text', Drupal.t('End tour')); + $tour.find('li').each(function (index) { + var progress = Drupal.t('!tour_item of !total', { '!tour_item': index + 1, '!total': total }); + $(this).find('.tour-progress').text(progress); + }).eq(-1).attr('data-text', Drupal.t('End tour')); } } }); - -})(jQuery, Backbone, Drupal, document); +})(jQuery, Backbone, Drupal, document); \ No newline at end of file diff --git a/core/modules/tracker/js/tracker-history.es6.js b/core/modules/tracker/js/tracker-history.es6.js new file mode 100644 index 000000000000..877a9caec71b --- /dev/null +++ b/core/modules/tracker/js/tracker-history.es6.js @@ -0,0 +1,122 @@ +/** + * Attaches behaviors for the Tracker module's History module integration. + * + * May only be loaded for authenticated users, with the History module enabled. + */ +(function ($, Drupal, window) { + + 'use strict'; + + /** + * Render "new" and "updated" node indicators, as well as "X new" replies links. + */ + Drupal.behaviors.trackerHistory = { + attach: function (context) { + // Find all "new" comment indicator placeholders newer than 30 days ago that + // have not already been read after their last comment timestamp. + var nodeIDs = []; + var $nodeNewPlaceholders = $(context) + .find('[data-history-node-timestamp]') + .once('history') + .filter(function () { + var nodeTimestamp = parseInt(this.getAttribute('data-history-node-timestamp'), 10); + var nodeID = this.getAttribute('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, nodeTimestamp)) { + nodeIDs.push(nodeID); + return true; + } + else { + return false; + } + }); + + // Find all "new" comment indicator placeholders newer than 30 days ago that + // have not already been read after their last comment timestamp. + var $newRepliesPlaceholders = $(context) + .find('[data-history-node-last-comment-timestamp]') + .once('history') + .filter(function () { + var lastCommentTimestamp = parseInt(this.getAttribute('data-history-node-last-comment-timestamp'), 10); + var nodeTimestamp = parseInt(this.previousSibling.previousSibling.getAttribute('data-history-node-timestamp'), 10); + // Discard placeholders that have zero comments. + if (lastCommentTimestamp === nodeTimestamp) { + return false; + } + var nodeID = this.previousSibling.previousSibling.getAttribute('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) { + if (nodeIDs.indexOf(nodeID) === -1) { + nodeIDs.push(nodeID); + } + return true; + } + else { + return false; + } + }); + + if ($nodeNewPlaceholders.length === 0 && $newRepliesPlaceholders.length === 0) { + return; + } + + // Fetch the node read timestamps from the server. + Drupal.history.fetchTimestamps(nodeIDs, function () { + processNodeNewIndicators($nodeNewPlaceholders); + processNewRepliesIndicators($newRepliesPlaceholders); + }); + } + }; + + function processNodeNewIndicators($placeholders) { + var newNodeString = Drupal.t('new'); + var updatedNodeString = Drupal.t('updated'); + + $placeholders.each(function (index, placeholder) { + var timestamp = parseInt(placeholder.getAttribute('data-history-node-timestamp'), 10); + var nodeID = placeholder.getAttribute('data-history-node-id'); + var lastViewTimestamp = Drupal.history.getLastRead(nodeID); + + if (timestamp > lastViewTimestamp) { + var message = (lastViewTimestamp === 0) ? newNodeString : updatedNodeString; + $(placeholder).append('<span class="marker">' + message + '</span>'); + } + }); + } + + function processNewRepliesIndicators($placeholders) { + // Figure out which placeholders need the "x new" replies links. + var placeholdersToUpdate = {}; + $placeholders.each(function (index, placeholder) { + var timestamp = parseInt(placeholder.getAttribute('data-history-node-last-comment-timestamp'), 10); + var nodeID = placeholder.previousSibling.previousSibling.getAttribute('data-history-node-id'); + var lastViewTimestamp = Drupal.history.getLastRead(nodeID); + + // Queue this placeholder's "X new" replies link to be downloaded from the + // server. + if (timestamp > lastViewTimestamp) { + placeholdersToUpdate[nodeID] = placeholder; + } + }); + + // Perform an AJAX request to retrieve node view timestamps. + var nodeIDs = Object.keys(placeholdersToUpdate); + if (nodeIDs.length === 0) { + return; + } + $.ajax({ + url: Drupal.url('comments/render_new_comments_node_links'), + type: 'POST', + data: {'node_ids[]': nodeIDs}, + dataType: 'json', + success: function (results) { + for (var nodeID in results) { + if (results.hasOwnProperty(nodeID) && placeholdersToUpdate.hasOwnProperty(nodeID)) { + var url = results[nodeID].first_new_comment_link; + var text = Drupal.formatPlural(results[nodeID].new_comment_count, '1 new', '@count new'); + $(placeholdersToUpdate[nodeID]).append('<br /><a href="' + url + '">' + text + '</a>'); + } + } + } + }); + } + +})(jQuery, Drupal, window); diff --git a/core/modules/tracker/js/tracker-history.js b/core/modules/tracker/js/tracker-history.js index 877a9caec71b..386c07c2134e 100644 --- a/core/modules/tracker/js/tracker-history.js +++ b/core/modules/tracker/js/tracker-history.js @@ -1,64 +1,51 @@ /** - * Attaches behaviors for the Tracker module's History module integration. - * - * May only be loaded for authenticated users, with the History module enabled. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/tracker/js/tracker-history.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + (function ($, Drupal, window) { 'use strict'; - /** - * Render "new" and "updated" node indicators, as well as "X new" replies links. - */ Drupal.behaviors.trackerHistory = { - attach: function (context) { - // Find all "new" comment indicator placeholders newer than 30 days ago that - // have not already been read after their last comment timestamp. + attach: function attach(context) { var nodeIDs = []; - var $nodeNewPlaceholders = $(context) - .find('[data-history-node-timestamp]') - .once('history') - .filter(function () { - var nodeTimestamp = parseInt(this.getAttribute('data-history-node-timestamp'), 10); - var nodeID = this.getAttribute('data-history-node-id'); - if (Drupal.history.needsServerCheck(nodeID, nodeTimestamp)) { - nodeIDs.push(nodeID); - return true; - } - else { - return false; - } - }); + var $nodeNewPlaceholders = $(context).find('[data-history-node-timestamp]').once('history').filter(function () { + var nodeTimestamp = parseInt(this.getAttribute('data-history-node-timestamp'), 10); + var nodeID = this.getAttribute('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, nodeTimestamp)) { + nodeIDs.push(nodeID); + return true; + } else { + return false; + } + }); - // Find all "new" comment indicator placeholders newer than 30 days ago that - // have not already been read after their last comment timestamp. - var $newRepliesPlaceholders = $(context) - .find('[data-history-node-last-comment-timestamp]') - .once('history') - .filter(function () { - var lastCommentTimestamp = parseInt(this.getAttribute('data-history-node-last-comment-timestamp'), 10); - var nodeTimestamp = parseInt(this.previousSibling.previousSibling.getAttribute('data-history-node-timestamp'), 10); - // Discard placeholders that have zero comments. - if (lastCommentTimestamp === nodeTimestamp) { - return false; - } - var nodeID = this.previousSibling.previousSibling.getAttribute('data-history-node-id'); - if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) { - if (nodeIDs.indexOf(nodeID) === -1) { - nodeIDs.push(nodeID); - } - return true; - } - else { - return false; + var $newRepliesPlaceholders = $(context).find('[data-history-node-last-comment-timestamp]').once('history').filter(function () { + var lastCommentTimestamp = parseInt(this.getAttribute('data-history-node-last-comment-timestamp'), 10); + var nodeTimestamp = parseInt(this.previousSibling.previousSibling.getAttribute('data-history-node-timestamp'), 10); + + if (lastCommentTimestamp === nodeTimestamp) { + return false; + } + var nodeID = this.previousSibling.previousSibling.getAttribute('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) { + if (nodeIDs.indexOf(nodeID) === -1) { + nodeIDs.push(nodeID); } - }); + return true; + } else { + return false; + } + }); if ($nodeNewPlaceholders.length === 0 && $newRepliesPlaceholders.length === 0) { return; } - // Fetch the node read timestamps from the server. Drupal.history.fetchTimestamps(nodeIDs, function () { processNodeNewIndicators($nodeNewPlaceholders); processNewRepliesIndicators($newRepliesPlaceholders); @@ -76,28 +63,24 @@ var lastViewTimestamp = Drupal.history.getLastRead(nodeID); if (timestamp > lastViewTimestamp) { - var message = (lastViewTimestamp === 0) ? newNodeString : updatedNodeString; + var message = lastViewTimestamp === 0 ? newNodeString : updatedNodeString; $(placeholder).append('<span class="marker">' + message + '</span>'); } }); } function processNewRepliesIndicators($placeholders) { - // Figure out which placeholders need the "x new" replies links. var placeholdersToUpdate = {}; $placeholders.each(function (index, placeholder) { var timestamp = parseInt(placeholder.getAttribute('data-history-node-last-comment-timestamp'), 10); var nodeID = placeholder.previousSibling.previousSibling.getAttribute('data-history-node-id'); var lastViewTimestamp = Drupal.history.getLastRead(nodeID); - // Queue this placeholder's "X new" replies link to be downloaded from the - // server. if (timestamp > lastViewTimestamp) { placeholdersToUpdate[nodeID] = placeholder; } }); - // Perform an AJAX request to retrieve node view timestamps. var nodeIDs = Object.keys(placeholdersToUpdate); if (nodeIDs.length === 0) { return; @@ -105,9 +88,9 @@ $.ajax({ url: Drupal.url('comments/render_new_comments_node_links'), type: 'POST', - data: {'node_ids[]': nodeIDs}, + data: { 'node_ids[]': nodeIDs }, dataType: 'json', - success: function (results) { + success: function success(results) { for (var nodeID in results) { if (results.hasOwnProperty(nodeID) && placeholdersToUpdate.hasOwnProperty(nodeID)) { var url = results[nodeID].first_new_comment_link; @@ -118,5 +101,4 @@ } }); } - -})(jQuery, Drupal, window); +})(jQuery, Drupal, window); \ No newline at end of file diff --git a/core/modules/user/user.es6.js b/core/modules/user/user.es6.js new file mode 100644 index 000000000000..f4602c676038 --- /dev/null +++ b/core/modules/user/user.es6.js @@ -0,0 +1,217 @@ +/** + * @file + * User behaviors. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Attach handlers to evaluate the strength of any password fields and to + * check that its confirmation is correct. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches password strength indicator and other relevant validation to + * password fields. + */ + Drupal.behaviors.password = { + attach: function (context, settings) { + var $passwordInput = $(context).find('input.js-password-field').once('password'); + + if ($passwordInput.length) { + var translate = settings.password; + + var $passwordInputParent = $passwordInput.parent(); + var $passwordInputParentWrapper = $passwordInputParent.parent(); + var $passwordSuggestions; + + // Add identifying class to password element parent. + $passwordInputParent.addClass('password-parent'); + + // Add the password confirmation layer. + $passwordInputParentWrapper + .find('input.js-password-confirm') + .parent() + .append('<div aria-live="polite" aria-atomic="true" class="password-confirm js-password-confirm">' + translate.confirmTitle + ' <span></span></div>') + .addClass('confirm-parent'); + + var $confirmInput = $passwordInputParentWrapper.find('input.js-password-confirm'); + var $confirmResult = $passwordInputParentWrapper.find('div.js-password-confirm'); + var $confirmChild = $confirmResult.find('span'); + + // If the password strength indicator is enabled, add its markup. + if (settings.password.showStrengthIndicator) { + var passwordMeter = '<div class="password-strength"><div class="password-strength__meter"><div class="password-strength__indicator js-password-strength__indicator"></div></div><div aria-live="polite" aria-atomic="true" class="password-strength__title">' + translate.strengthTitle + ' <span class="password-strength__text js-password-strength__text"></span></div></div>'; + $confirmInput.parent().after('<div class="password-suggestions description"></div>'); + $passwordInputParent.append(passwordMeter); + $passwordSuggestions = $passwordInputParentWrapper.find('div.password-suggestions').hide(); + } + + // Check that password and confirmation inputs match. + var passwordCheckMatch = function (confirmInputVal) { + var success = $passwordInput.val() === confirmInputVal; + var confirmClass = success ? 'ok' : 'error'; + + // Fill in the success message and set the class accordingly. + $confirmChild.html(translate['confirm' + (success ? 'Success' : 'Failure')]) + .removeClass('ok error').addClass(confirmClass); + }; + + // Check the password strength. + var passwordCheck = function () { + if (settings.password.showStrengthIndicator) { + // Evaluate the password strength. + var result = Drupal.evaluatePasswordStrength($passwordInput.val(), settings.password); + + // Update the suggestions for how to improve the password. + if ($passwordSuggestions.html() !== result.message) { + $passwordSuggestions.html(result.message); + } + + // Only show the description box if a weakness exists in the + // password. + $passwordSuggestions.toggle(result.strength !== 100); + + // Adjust the length of the strength indicator. + $passwordInputParent.find('.js-password-strength__indicator') + .css('width', result.strength + '%') + .removeClass('is-weak is-fair is-good is-strong') + .addClass(result.indicatorClass); + + // Update the strength indication text. + $passwordInputParent.find('.js-password-strength__text').html(result.indicatorText); + } + + // Check the value in the confirm input and show results. + if ($confirmInput.val()) { + passwordCheckMatch($confirmInput.val()); + $confirmResult.css({visibility: 'visible'}); + } + else { + $confirmResult.css({visibility: 'hidden'}); + } + }; + + // Monitor input events. + $passwordInput.on('input', passwordCheck); + $confirmInput.on('input', passwordCheck); + } + } + }; + + /** + * Evaluate the strength of a user's password. + * + * Returns the estimated strength and the relevant output message. + * + * @param {string} password + * The password to evaluate. + * @param {object} translate + * An object containing the text to display for each strength level. + * + * @return {object} + * An object containing strength, message, indicatorText and indicatorClass. + */ + Drupal.evaluatePasswordStrength = function (password, translate) { + password = password.trim(); + var indicatorText; + var indicatorClass; + var weaknesses = 0; + var strength = 100; + var msg = []; + + var hasLowercase = /[a-z]/.test(password); + var hasUppercase = /[A-Z]/.test(password); + var hasNumbers = /[0-9]/.test(password); + var hasPunctuation = /[^a-zA-Z0-9]/.test(password); + + // If there is a username edit box on the page, compare password to that, + // otherwise use value from the database. + var $usernameBox = $('input.username'); + var username = ($usernameBox.length > 0) ? $usernameBox.val() : translate.username; + + // Lose 5 points for every character less than 12, plus a 30 point penalty. + if (password.length < 12) { + msg.push(translate.tooShort); + strength -= ((12 - password.length) * 5) + 30; + } + + // Count weaknesses. + if (!hasLowercase) { + msg.push(translate.addLowerCase); + weaknesses++; + } + if (!hasUppercase) { + msg.push(translate.addUpperCase); + weaknesses++; + } + if (!hasNumbers) { + msg.push(translate.addNumbers); + weaknesses++; + } + if (!hasPunctuation) { + msg.push(translate.addPunctuation); + weaknesses++; + } + + // Apply penalty for each weakness (balanced against length penalty). + switch (weaknesses) { + case 1: + strength -= 12.5; + break; + + case 2: + strength -= 25; + break; + + case 3: + strength -= 40; + break; + + case 4: + strength -= 40; + break; + } + + // Check if password is the same as the username. + if (password !== '' && password.toLowerCase() === username.toLowerCase()) { + msg.push(translate.sameAsUsername); + // Passwords the same as username are always very weak. + strength = 5; + } + + // Based on the strength, work out what text should be shown by the + // password strength meter. + if (strength < 60) { + indicatorText = translate.weak; + indicatorClass = 'is-weak'; + } + else if (strength < 70) { + indicatorText = translate.fair; + indicatorClass = 'is-fair'; + } + else if (strength < 80) { + indicatorText = translate.good; + indicatorClass = 'is-good'; + } + else if (strength <= 100) { + indicatorText = translate.strong; + indicatorClass = 'is-strong'; + } + + // Assemble the final message. + msg = translate.hasWeaknesses + '<ul><li>' + msg.join('</li><li>') + '</li></ul>'; + + return { + strength: strength, + message: msg, + indicatorText: indicatorText, + indicatorClass: indicatorClass + }; + + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/user/user.js b/core/modules/user/user.js index f4602c676038..ce7b85e501d8 100644 --- a/core/modules/user/user.js +++ b/core/modules/user/user.js @@ -1,24 +1,17 @@ /** - * @file - * User behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/user/user.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Attach handlers to evaluate the strength of any password fields and to - * check that its confirmation is correct. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches password strength indicator and other relevant validation to - * password fields. - */ Drupal.behaviors.password = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $passwordInput = $(context).find('input.js-password-field').once('password'); if ($passwordInput.length) { @@ -28,21 +21,14 @@ var $passwordInputParentWrapper = $passwordInputParent.parent(); var $passwordSuggestions; - // Add identifying class to password element parent. $passwordInputParent.addClass('password-parent'); - // Add the password confirmation layer. - $passwordInputParentWrapper - .find('input.js-password-confirm') - .parent() - .append('<div aria-live="polite" aria-atomic="true" class="password-confirm js-password-confirm">' + translate.confirmTitle + ' <span></span></div>') - .addClass('confirm-parent'); + $passwordInputParentWrapper.find('input.js-password-confirm').parent().append('<div aria-live="polite" aria-atomic="true" class="password-confirm js-password-confirm">' + translate.confirmTitle + ' <span></span></div>').addClass('confirm-parent'); var $confirmInput = $passwordInputParentWrapper.find('input.js-password-confirm'); var $confirmResult = $passwordInputParentWrapper.find('div.js-password-confirm'); var $confirmChild = $confirmResult.find('span'); - // If the password strength indicator is enabled, add its markup. if (settings.password.showStrengthIndicator) { var passwordMeter = '<div class="password-strength"><div class="password-strength__meter"><div class="password-strength__indicator js-password-strength__indicator"></div></div><div aria-live="polite" aria-atomic="true" class="password-strength__title">' + translate.strengthTitle + ' <span class="password-strength__text js-password-strength__text"></span></div></div>'; $confirmInput.parent().after('<div class="password-suggestions description"></div>'); @@ -50,71 +36,42 @@ $passwordSuggestions = $passwordInputParentWrapper.find('div.password-suggestions').hide(); } - // Check that password and confirmation inputs match. - var passwordCheckMatch = function (confirmInputVal) { + var passwordCheckMatch = function passwordCheckMatch(confirmInputVal) { var success = $passwordInput.val() === confirmInputVal; var confirmClass = success ? 'ok' : 'error'; - // Fill in the success message and set the class accordingly. - $confirmChild.html(translate['confirm' + (success ? 'Success' : 'Failure')]) - .removeClass('ok error').addClass(confirmClass); + $confirmChild.html(translate['confirm' + (success ? 'Success' : 'Failure')]).removeClass('ok error').addClass(confirmClass); }; - // Check the password strength. - var passwordCheck = function () { + var passwordCheck = function passwordCheck() { if (settings.password.showStrengthIndicator) { - // Evaluate the password strength. var result = Drupal.evaluatePasswordStrength($passwordInput.val(), settings.password); - // Update the suggestions for how to improve the password. if ($passwordSuggestions.html() !== result.message) { $passwordSuggestions.html(result.message); } - // Only show the description box if a weakness exists in the - // password. $passwordSuggestions.toggle(result.strength !== 100); - // Adjust the length of the strength indicator. - $passwordInputParent.find('.js-password-strength__indicator') - .css('width', result.strength + '%') - .removeClass('is-weak is-fair is-good is-strong') - .addClass(result.indicatorClass); + $passwordInputParent.find('.js-password-strength__indicator').css('width', result.strength + '%').removeClass('is-weak is-fair is-good is-strong').addClass(result.indicatorClass); - // Update the strength indication text. $passwordInputParent.find('.js-password-strength__text').html(result.indicatorText); } - // Check the value in the confirm input and show results. if ($confirmInput.val()) { passwordCheckMatch($confirmInput.val()); - $confirmResult.css({visibility: 'visible'}); - } - else { - $confirmResult.css({visibility: 'hidden'}); + $confirmResult.css({ visibility: 'visible' }); + } else { + $confirmResult.css({ visibility: 'hidden' }); } }; - // Monitor input events. $passwordInput.on('input', passwordCheck); $confirmInput.on('input', passwordCheck); } } }; - /** - * Evaluate the strength of a user's password. - * - * Returns the estimated strength and the relevant output message. - * - * @param {string} password - * The password to evaluate. - * @param {object} translate - * An object containing the text to display for each strength level. - * - * @return {object} - * An object containing strength, message, indicatorText and indicatorClass. - */ Drupal.evaluatePasswordStrength = function (password, translate) { password = password.trim(); var indicatorText; @@ -128,18 +85,14 @@ var hasNumbers = /[0-9]/.test(password); var hasPunctuation = /[^a-zA-Z0-9]/.test(password); - // If there is a username edit box on the page, compare password to that, - // otherwise use value from the database. var $usernameBox = $('input.username'); - var username = ($usernameBox.length > 0) ? $usernameBox.val() : translate.username; + var username = $usernameBox.length > 0 ? $usernameBox.val() : translate.username; - // Lose 5 points for every character less than 12, plus a 30 point penalty. if (password.length < 12) { msg.push(translate.tooShort); - strength -= ((12 - password.length) * 5) + 30; + strength -= (12 - password.length) * 5 + 30; } - // Count weaknesses. if (!hasLowercase) { msg.push(translate.addLowerCase); weaknesses++; @@ -157,7 +110,6 @@ weaknesses++; } - // Apply penalty for each weakness (balanced against length penalty). switch (weaknesses) { case 1: strength -= 12.5; @@ -176,33 +128,26 @@ break; } - // Check if password is the same as the username. if (password !== '' && password.toLowerCase() === username.toLowerCase()) { msg.push(translate.sameAsUsername); - // Passwords the same as username are always very weak. + strength = 5; } - // Based on the strength, work out what text should be shown by the - // password strength meter. if (strength < 60) { indicatorText = translate.weak; indicatorClass = 'is-weak'; - } - else if (strength < 70) { + } else if (strength < 70) { indicatorText = translate.fair; indicatorClass = 'is-fair'; - } - else if (strength < 80) { + } else if (strength < 80) { indicatorText = translate.good; indicatorClass = 'is-good'; - } - else if (strength <= 100) { + } else if (strength <= 100) { indicatorText = translate.strong; indicatorClass = 'is-strong'; } - // Assemble the final message. msg = translate.hasWeaknesses + '<ul><li>' + msg.join('</li><li>') + '</li></ul>'; return { @@ -211,7 +156,5 @@ indicatorText: indicatorText, indicatorClass: indicatorClass }; - }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/user/user.permissions.es6.js b/core/modules/user/user.permissions.es6.js new file mode 100644 index 000000000000..c3dc24f1f0a6 --- /dev/null +++ b/core/modules/user/user.permissions.es6.js @@ -0,0 +1,88 @@ +/** + * @file + * User permission page behaviors. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Shows checked and disabled checkboxes for inherited permissions. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches functionality to the permissions table. + */ + Drupal.behaviors.permissions = { + attach: function (context) { + var self = this; + $('table#permissions').once('permissions').each(function () { + // On a site with many roles and permissions, this behavior initially + // has to perform thousands of DOM manipulations to inject checkboxes + // and hide them. By detaching the table from the DOM, all operations + // can be performed without triggering internal layout and re-rendering + // processes in the browser. + var $table = $(this); + var $ancestor; + var method; + if ($table.prev().length) { + $ancestor = $table.prev(); + method = 'after'; + } + else { + $ancestor = $table.parent(); + method = 'append'; + } + $table.detach(); + + // Create dummy checkboxes. We use dummy checkboxes instead of reusing + // the existing checkboxes here because new checkboxes don't alter the + // submitted form. If we'd automatically check existing checkboxes, the + // permission table would be polluted with redundant entries. This + // is deliberate, but desirable when we automatically check them. + var $dummy = $('<input type="checkbox" class="dummy-checkbox js-dummy-checkbox" disabled="disabled" checked="checked" />') + .attr('title', Drupal.t('This permission is inherited from the authenticated user role.')) + .hide(); + + $table + .find('input[type="checkbox"]') + .not('.js-rid-anonymous, .js-rid-authenticated') + .addClass('real-checkbox js-real-checkbox') + .after($dummy); + + // Initialize the authenticated user checkbox. + $table.find('input[type=checkbox].js-rid-authenticated') + .on('click.permissions', self.toggle) + // .triggerHandler() cannot be used here, as it only affects the first + // element. + .each(self.toggle); + + // Re-insert the table into the DOM. + $ancestor[method]($table); + }); + }, + + /** + * Toggles all dummy checkboxes based on the checkboxes' state. + * + * If the "authenticated user" checkbox is checked, the checked and disabled + * checkboxes are shown, the real checkboxes otherwise. + */ + toggle: function () { + var authCheckbox = this; + var $row = $(this).closest('tr'); + // jQuery performs too many layout calculations for .hide() and .show(), + // leading to a major page rendering lag on sites with many roles and + // permissions. Therefore, we toggle visibility directly. + $row.find('.js-real-checkbox').each(function () { + this.style.display = (authCheckbox.checked ? 'none' : ''); + }); + $row.find('.js-dummy-checkbox').each(function () { + this.style.display = (authCheckbox.checked ? '' : 'none'); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/modules/user/user.permissions.js b/core/modules/user/user.permissions.js index c3dc24f1f0a6..f7c8b82e21a7 100644 --- a/core/modules/user/user.permissions.js +++ b/core/modules/user/user.permissions.js @@ -1,88 +1,51 @@ /** - * @file - * User permission page behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/user/user.permissions.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Shows checked and disabled checkboxes for inherited permissions. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches functionality to the permissions table. - */ Drupal.behaviors.permissions = { - attach: function (context) { + attach: function attach(context) { var self = this; $('table#permissions').once('permissions').each(function () { - // On a site with many roles and permissions, this behavior initially - // has to perform thousands of DOM manipulations to inject checkboxes - // and hide them. By detaching the table from the DOM, all operations - // can be performed without triggering internal layout and re-rendering - // processes in the browser. var $table = $(this); var $ancestor; var method; if ($table.prev().length) { $ancestor = $table.prev(); method = 'after'; - } - else { + } else { $ancestor = $table.parent(); method = 'append'; } $table.detach(); - // Create dummy checkboxes. We use dummy checkboxes instead of reusing - // the existing checkboxes here because new checkboxes don't alter the - // submitted form. If we'd automatically check existing checkboxes, the - // permission table would be polluted with redundant entries. This - // is deliberate, but desirable when we automatically check them. - var $dummy = $('<input type="checkbox" class="dummy-checkbox js-dummy-checkbox" disabled="disabled" checked="checked" />') - .attr('title', Drupal.t('This permission is inherited from the authenticated user role.')) - .hide(); + var $dummy = $('<input type="checkbox" class="dummy-checkbox js-dummy-checkbox" disabled="disabled" checked="checked" />').attr('title', Drupal.t('This permission is inherited from the authenticated user role.')).hide(); - $table - .find('input[type="checkbox"]') - .not('.js-rid-anonymous, .js-rid-authenticated') - .addClass('real-checkbox js-real-checkbox') - .after($dummy); + $table.find('input[type="checkbox"]').not('.js-rid-anonymous, .js-rid-authenticated').addClass('real-checkbox js-real-checkbox').after($dummy); - // Initialize the authenticated user checkbox. - $table.find('input[type=checkbox].js-rid-authenticated') - .on('click.permissions', self.toggle) - // .triggerHandler() cannot be used here, as it only affects the first - // element. - .each(self.toggle); + $table.find('input[type=checkbox].js-rid-authenticated').on('click.permissions', self.toggle).each(self.toggle); - // Re-insert the table into the DOM. $ancestor[method]($table); }); }, - /** - * Toggles all dummy checkboxes based on the checkboxes' state. - * - * If the "authenticated user" checkbox is checked, the checked and disabled - * checkboxes are shown, the real checkboxes otherwise. - */ - toggle: function () { + toggle: function toggle() { var authCheckbox = this; var $row = $(this).closest('tr'); - // jQuery performs too many layout calculations for .hide() and .show(), - // leading to a major page rendering lag on sites with many roles and - // permissions. Therefore, we toggle visibility directly. + $row.find('.js-real-checkbox').each(function () { - this.style.display = (authCheckbox.checked ? 'none' : ''); + this.style.display = authCheckbox.checked ? 'none' : ''; }); $row.find('.js-dummy-checkbox').each(function () { - this.style.display = (authCheckbox.checked ? '' : 'none'); + this.style.display = authCheckbox.checked ? '' : 'none'; }); } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/views/js/ajax_view.es6.js b/core/modules/views/js/ajax_view.es6.js new file mode 100644 index 000000000000..85975b56ce35 --- /dev/null +++ b/core/modules/views/js/ajax_view.es6.js @@ -0,0 +1,205 @@ +/** + * @file + * Handles AJAX fetching of views, including filter submission and response. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Attaches the AJAX behavior to exposed filters forms and key View links. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches ajaxView functionality to relevant elements. + */ + Drupal.behaviors.ViewsAjaxView = {}; + Drupal.behaviors.ViewsAjaxView.attach = function () { + if (drupalSettings && drupalSettings.views && drupalSettings.views.ajaxViews) { + var ajaxViews = drupalSettings.views.ajaxViews; + for (var i in ajaxViews) { + if (ajaxViews.hasOwnProperty(i)) { + Drupal.views.instances[i] = new Drupal.views.ajaxView(ajaxViews[i]); + } + } + } + }; + + /** + * @namespace + */ + Drupal.views = {}; + + /** + * @type {object.<string, Drupal.views.ajaxView>} + */ + Drupal.views.instances = {}; + + /** + * Javascript object for a certain view. + * + * @constructor + * + * @param {object} settings + * Settings object for the ajax view. + * @param {string} settings.view_dom_id + * The DOM id of the view. + */ + Drupal.views.ajaxView = function (settings) { + var selector = '.js-view-dom-id-' + settings.view_dom_id; + this.$view = $(selector); + + // Retrieve the path to use for views' ajax. + var ajax_path = drupalSettings.views.ajax_path; + + // If there are multiple views this might've ended up showing up multiple + // times. + if (ajax_path.constructor.toString().indexOf('Array') !== -1) { + ajax_path = ajax_path[0]; + } + + // Check if there are any GET parameters to send to views. + var queryString = window.location.search || ''; + if (queryString !== '') { + // Remove the question mark and Drupal path component if any. + queryString = queryString.slice(1).replace(/q=[^&]+&?|&?render=[^&]+/, ''); + if (queryString !== '') { + // If there is a '?' in ajax_path, clean url are on and & should be + // used to add parameters. + queryString = ((/\?/.test(ajax_path)) ? '&' : '?') + queryString; + } + } + + this.element_settings = { + url: ajax_path + queryString, + submit: settings, + setClick: true, + event: 'click', + selector: selector, + progress: {type: 'fullscreen'} + }; + + this.settings = settings; + + // Add the ajax to exposed forms. + this.$exposed_form = $('form#views-exposed-form-' + settings.view_name.replace(/_/g, '-') + '-' + settings.view_display_id.replace(/_/g, '-')); + this.$exposed_form.once('exposed-form').each($.proxy(this.attachExposedFormAjax, this)); + + // Add the ajax to pagers. + this.$view + // Don't attach to nested views. Doing so would attach multiple behaviors + // to a given element. + .filter($.proxy(this.filterNestedViews, this)) + .once('ajax-pager').each($.proxy(this.attachPagerAjax, this)); + + // Add a trigger to update this view specifically. In order to trigger a + // refresh use the following code. + // + // @code + // $('.view-name').trigger('RefreshView'); + // @endcode + var self_settings = $.extend({}, this.element_settings, { + event: 'RefreshView', + base: this.selector, + element: this.$view.get(0) + }); + this.refreshViewAjax = Drupal.ajax(self_settings); + }; + + /** + * @method + */ + Drupal.views.ajaxView.prototype.attachExposedFormAjax = function () { + var that = this; + this.exposedFormAjax = []; + // Exclude the reset buttons so no AJAX behaviours are bound. Many things + // break during the form reset phase if using AJAX. + $('input[type=submit], input[type=image]', this.$exposed_form).not('[data-drupal-selector=edit-reset]').each(function (index) { + var self_settings = $.extend({}, that.element_settings, { + base: $(this).attr('id'), + element: this + }); + that.exposedFormAjax[index] = Drupal.ajax(self_settings); + }); + }; + + /** + * @return {bool} + * If there is at least one parent with a view class return false. + */ + Drupal.views.ajaxView.prototype.filterNestedViews = function () { + // If there is at least one parent with a view class, this view + // is nested (e.g., an attachment). Bail. + return !this.$view.parents('.view').length; + }; + + /** + * Attach the ajax behavior to each link. + */ + Drupal.views.ajaxView.prototype.attachPagerAjax = function () { + this.$view.find('ul.js-pager__items > li > a, th.views-field a, .attachment .views-summary a') + .each($.proxy(this.attachPagerLinkAjax, this)); + }; + + /** + * Attach the ajax behavior to a singe link. + * + * @param {string} [id] + * The ID of the link. + * @param {HTMLElement} link + * The link element. + */ + Drupal.views.ajaxView.prototype.attachPagerLinkAjax = function (id, link) { + var $link = $(link); + var viewData = {}; + var href = $link.attr('href'); + // Construct an object using the settings defaults and then overriding + // with data specific to the link. + $.extend( + viewData, + this.settings, + Drupal.Views.parseQueryString(href), + // Extract argument data from the URL. + Drupal.Views.parseViewArgs(href, this.settings.view_base_path) + ); + + var self_settings = $.extend({}, this.element_settings, { + submit: viewData, + base: false, + element: link + }); + this.pagerAjax = Drupal.ajax(self_settings); + }; + + /** + * Views scroll to top ajax command. + * + * @param {Drupal.Ajax} [ajax] + * A {@link Drupal.ajax} object. + * @param {object} response + * Ajax response. + * @param {string} response.selector + * Selector to use. + */ + Drupal.AjaxCommands.prototype.viewsScrollTop = function (ajax, response) { + // Scroll to the top of the view. This will allow users + // to browse newly loaded content after e.g. clicking a pager + // link. + var offset = $(response.selector).offset(); + // We can't guarantee that the scrollable object should be + // the body, as the view could be embedded in something + // more complex such as a modal popup. Recurse up the DOM + // and scroll the first element that has a non-zero top. + var scrollTarget = response.selector; + while ($(scrollTarget).scrollTop() === 0 && $(scrollTarget).parent()) { + scrollTarget = $(scrollTarget).parent(); + } + // Only scroll upward. + if (offset.top - 10 < $(scrollTarget).scrollTop()) { + $(scrollTarget).animate({scrollTop: (offset.top - 10)}, 500); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/views/js/ajax_view.js b/core/modules/views/js/ajax_view.js index 85975b56ce35..876cdf11e140 100644 --- a/core/modules/views/js/ajax_view.js +++ b/core/modules/views/js/ajax_view.js @@ -1,20 +1,15 @@ /** - * @file - * Handles AJAX fetching of views, including filter submission and response. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/views/js/ajax_view.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Attaches the AJAX behavior to exposed filters forms and key View links. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches ajaxView functionality to relevant elements. - */ Drupal.behaviors.ViewsAjaxView = {}; Drupal.behaviors.ViewsAjaxView.attach = function () { if (drupalSettings && drupalSettings.views && drupalSettings.views.ajaxViews) { @@ -27,48 +22,25 @@ } }; - /** - * @namespace - */ Drupal.views = {}; - /** - * @type {object.<string, Drupal.views.ajaxView>} - */ Drupal.views.instances = {}; - /** - * Javascript object for a certain view. - * - * @constructor - * - * @param {object} settings - * Settings object for the ajax view. - * @param {string} settings.view_dom_id - * The DOM id of the view. - */ Drupal.views.ajaxView = function (settings) { var selector = '.js-view-dom-id-' + settings.view_dom_id; this.$view = $(selector); - // Retrieve the path to use for views' ajax. var ajax_path = drupalSettings.views.ajax_path; - // If there are multiple views this might've ended up showing up multiple - // times. if (ajax_path.constructor.toString().indexOf('Array') !== -1) { ajax_path = ajax_path[0]; } - // Check if there are any GET parameters to send to views. var queryString = window.location.search || ''; if (queryString !== '') { - // Remove the question mark and Drupal path component if any. queryString = queryString.slice(1).replace(/q=[^&]+&?|&?render=[^&]+/, ''); if (queryString !== '') { - // If there is a '?' in ajax_path, clean url are on and & should be - // used to add parameters. - queryString = ((/\?/.test(ajax_path)) ? '&' : '?') + queryString; + queryString = (/\?/.test(ajax_path) ? '&' : '?') + queryString; } } @@ -78,28 +50,16 @@ setClick: true, event: 'click', selector: selector, - progress: {type: 'fullscreen'} + progress: { type: 'fullscreen' } }; this.settings = settings; - // Add the ajax to exposed forms. this.$exposed_form = $('form#views-exposed-form-' + settings.view_name.replace(/_/g, '-') + '-' + settings.view_display_id.replace(/_/g, '-')); this.$exposed_form.once('exposed-form').each($.proxy(this.attachExposedFormAjax, this)); - // Add the ajax to pagers. - this.$view - // Don't attach to nested views. Doing so would attach multiple behaviors - // to a given element. - .filter($.proxy(this.filterNestedViews, this)) - .once('ajax-pager').each($.proxy(this.attachPagerAjax, this)); - - // Add a trigger to update this view specifically. In order to trigger a - // refresh use the following code. - // - // @code - // $('.view-name').trigger('RefreshView'); - // @endcode + this.$view.filter($.proxy(this.filterNestedViews, this)).once('ajax-pager').each($.proxy(this.attachPagerAjax, this)); + var self_settings = $.extend({}, this.element_settings, { event: 'RefreshView', base: this.selector, @@ -108,14 +68,10 @@ this.refreshViewAjax = Drupal.ajax(self_settings); }; - /** - * @method - */ Drupal.views.ajaxView.prototype.attachExposedFormAjax = function () { var that = this; this.exposedFormAjax = []; - // Exclude the reset buttons so no AJAX behaviours are bound. Many things - // break during the form reset phase if using AJAX. + $('input[type=submit], input[type=image]', this.$exposed_form).not('[data-drupal-selector=edit-reset]').each(function (index) { var self_settings = $.extend({}, that.element_settings, { base: $(this).attr('id'), @@ -125,45 +81,20 @@ }); }; - /** - * @return {bool} - * If there is at least one parent with a view class return false. - */ Drupal.views.ajaxView.prototype.filterNestedViews = function () { - // If there is at least one parent with a view class, this view - // is nested (e.g., an attachment). Bail. return !this.$view.parents('.view').length; }; - /** - * Attach the ajax behavior to each link. - */ Drupal.views.ajaxView.prototype.attachPagerAjax = function () { - this.$view.find('ul.js-pager__items > li > a, th.views-field a, .attachment .views-summary a') - .each($.proxy(this.attachPagerLinkAjax, this)); + this.$view.find('ul.js-pager__items > li > a, th.views-field a, .attachment .views-summary a').each($.proxy(this.attachPagerLinkAjax, this)); }; - /** - * Attach the ajax behavior to a singe link. - * - * @param {string} [id] - * The ID of the link. - * @param {HTMLElement} link - * The link element. - */ Drupal.views.ajaxView.prototype.attachPagerLinkAjax = function (id, link) { var $link = $(link); var viewData = {}; var href = $link.attr('href'); - // Construct an object using the settings defaults and then overriding - // with data specific to the link. - $.extend( - viewData, - this.settings, - Drupal.Views.parseQueryString(href), - // Extract argument data from the URL. - Drupal.Views.parseViewArgs(href, this.settings.view_base_path) - ); + + $.extend(viewData, this.settings, Drupal.Views.parseQueryString(href), Drupal.Views.parseViewArgs(href, this.settings.view_base_path)); var self_settings = $.extend({}, this.element_settings, { submit: viewData, @@ -173,33 +104,16 @@ this.pagerAjax = Drupal.ajax(self_settings); }; - /** - * Views scroll to top ajax command. - * - * @param {Drupal.Ajax} [ajax] - * A {@link Drupal.ajax} object. - * @param {object} response - * Ajax response. - * @param {string} response.selector - * Selector to use. - */ Drupal.AjaxCommands.prototype.viewsScrollTop = function (ajax, response) { - // Scroll to the top of the view. This will allow users - // to browse newly loaded content after e.g. clicking a pager - // link. var offset = $(response.selector).offset(); - // We can't guarantee that the scrollable object should be - // the body, as the view could be embedded in something - // more complex such as a modal popup. Recurse up the DOM - // and scroll the first element that has a non-zero top. + var scrollTarget = response.selector; while ($(scrollTarget).scrollTop() === 0 && $(scrollTarget).parent()) { scrollTarget = $(scrollTarget).parent(); } - // Only scroll upward. + if (offset.top - 10 < $(scrollTarget).scrollTop()) { - $(scrollTarget).animate({scrollTop: (offset.top - 10)}, 500); + $(scrollTarget).animate({ scrollTop: offset.top - 10 }, 500); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/views/js/base.es6.js b/core/modules/views/js/base.es6.js new file mode 100644 index 000000000000..cca3be4ddecd --- /dev/null +++ b/core/modules/views/js/base.es6.js @@ -0,0 +1,110 @@ +/** + * @file + * Some basic behaviors and utility functions for Views. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * @namespace + */ + Drupal.Views = {}; + + /** + * Helper function to parse a querystring. + * + * @param {string} query + * The querystring to parse. + * + * @return {object} + * A map of query parameters. + */ + Drupal.Views.parseQueryString = function (query) { + var args = {}; + var pos = query.indexOf('?'); + if (pos !== -1) { + query = query.substring(pos + 1); + } + var pair; + var pairs = query.split('&'); + for (var i = 0; i < pairs.length; i++) { + pair = pairs[i].split('='); + // Ignore the 'q' path argument, if present. + if (pair[0] !== 'q' && pair[1]) { + args[decodeURIComponent(pair[0].replace(/\+/g, ' '))] = decodeURIComponent(pair[1].replace(/\+/g, ' ')); + } + } + return args; + }; + + /** + * Helper function to return a view's arguments based on a path. + * + * @param {string} href + * The href to check. + * @param {string} viewPath + * The views path to check. + * + * @return {object} + * An object containing `view_args` and `view_path`. + */ + Drupal.Views.parseViewArgs = function (href, viewPath) { + var returnObj = {}; + var path = Drupal.Views.getPath(href); + // Get viewPath url without baseUrl portion. + var viewHref = Drupal.url(viewPath).substring(drupalSettings.path.baseUrl.length); + // Ensure we have a correct path. + if (viewHref && path.substring(0, viewHref.length + 1) === viewHref + '/') { + returnObj.view_args = decodeURIComponent(path.substring(viewHref.length + 1, path.length)); + returnObj.view_path = path; + } + return returnObj; + }; + + /** + * Strip off the protocol plus domain from an href. + * + * @param {string} href + * The href to strip. + * + * @return {string} + * The href without the protocol and domain. + */ + Drupal.Views.pathPortion = function (href) { + // Remove e.g. http://example.com if present. + var protocol = window.location.protocol; + if (href.substring(0, protocol.length) === protocol) { + // 2 is the length of the '//' that normally follows the protocol. + href = href.substring(href.indexOf('/', protocol.length + 2)); + } + return href; + }; + + /** + * Return the Drupal path portion of an href. + * + * @param {string} href + * The href to check. + * + * @return {string} + * An internal path. + */ + Drupal.Views.getPath = function (href) { + href = Drupal.Views.pathPortion(href); + href = href.substring(drupalSettings.path.baseUrl.length, href.length); + // 3 is the length of the '?q=' added to the url without clean urls. + if (href.substring(0, 3) === '?q=') { + href = href.substring(3, href.length); + } + var chars = ['#', '?', '&']; + for (var i = 0; i < chars.length; i++) { + if (href.indexOf(chars[i]) > -1) { + href = href.substr(0, href.indexOf(chars[i])); + } + } + return href; + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/views/js/base.js b/core/modules/views/js/base.js index cca3be4ddecd..510254be3dda 100644 --- a/core/modules/views/js/base.js +++ b/core/modules/views/js/base.js @@ -1,26 +1,17 @@ /** - * @file - * Some basic behaviors and utility functions for Views. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/views/js/base.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * @namespace - */ Drupal.Views = {}; - /** - * Helper function to parse a querystring. - * - * @param {string} query - * The querystring to parse. - * - * @return {object} - * A map of query parameters. - */ Drupal.Views.parseQueryString = function (query) { var args = {}; var pos = query.indexOf('?'); @@ -31,7 +22,7 @@ var pairs = query.split('&'); for (var i = 0; i < pairs.length; i++) { pair = pairs[i].split('='); - // Ignore the 'q' path argument, if present. + if (pair[0] !== 'q' && pair[1]) { args[decodeURIComponent(pair[0].replace(/\+/g, ' '))] = decodeURIComponent(pair[1].replace(/\+/g, ' ')); } @@ -39,23 +30,12 @@ return args; }; - /** - * Helper function to return a view's arguments based on a path. - * - * @param {string} href - * The href to check. - * @param {string} viewPath - * The views path to check. - * - * @return {object} - * An object containing `view_args` and `view_path`. - */ Drupal.Views.parseViewArgs = function (href, viewPath) { var returnObj = {}; var path = Drupal.Views.getPath(href); - // Get viewPath url without baseUrl portion. + var viewHref = Drupal.url(viewPath).substring(drupalSettings.path.baseUrl.length); - // Ensure we have a correct path. + if (viewHref && path.substring(0, viewHref.length + 1) === viewHref + '/') { returnObj.view_args = decodeURIComponent(path.substring(viewHref.length + 1, path.length)); returnObj.view_path = path; @@ -63,38 +43,18 @@ return returnObj; }; - /** - * Strip off the protocol plus domain from an href. - * - * @param {string} href - * The href to strip. - * - * @return {string} - * The href without the protocol and domain. - */ Drupal.Views.pathPortion = function (href) { - // Remove e.g. http://example.com if present. var protocol = window.location.protocol; if (href.substring(0, protocol.length) === protocol) { - // 2 is the length of the '//' that normally follows the protocol. href = href.substring(href.indexOf('/', protocol.length + 2)); } return href; }; - /** - * Return the Drupal path portion of an href. - * - * @param {string} href - * The href to check. - * - * @return {string} - * An internal path. - */ Drupal.Views.getPath = function (href) { href = Drupal.Views.pathPortion(href); href = href.substring(drupalSettings.path.baseUrl.length, href.length); - // 3 is the length of the '?q=' added to the url without clean urls. + if (href.substring(0, 3) === '?q=') { href = href.substring(3, href.length); } @@ -106,5 +66,4 @@ } return href; }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/views/tests/modules/views_test_data/views_cache.test.es6.js b/core/modules/views/tests/modules/views_test_data/views_cache.test.es6.js new file mode 100644 index 000000000000..4089267f9a5c --- /dev/null +++ b/core/modules/views/tests/modules/views_test_data/views_cache.test.es6.js @@ -0,0 +1,8 @@ +/** + * @file + * Just a placeholder file for the test. + * + * @see ViewsCacheTest::testHeaderStorage + * + * @ignore + */ diff --git a/core/modules/views/tests/modules/views_test_data/views_cache.test.js b/core/modules/views/tests/modules/views_test_data/views_cache.test.js index 4089267f9a5c..aa35fbaa96c2 100644 --- a/core/modules/views/tests/modules/views_test_data/views_cache.test.js +++ b/core/modules/views/tests/modules/views_test_data/views_cache.test.js @@ -1,8 +1,7 @@ /** - * @file - * Just a placeholder file for the test. - * - * @see ViewsCacheTest::testHeaderStorage - * - * @ignore - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/views/tests/modules/views_test_data/views_cache.test.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ \ No newline at end of file diff --git a/core/modules/views_ui/js/ajax.es6.js b/core/modules/views_ui/js/ajax.es6.js new file mode 100644 index 000000000000..d4ff7642f8fb --- /dev/null +++ b/core/modules/views_ui/js/ajax.es6.js @@ -0,0 +1,249 @@ +/** + * @file + * Handles AJAX submission and response in Views UI. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Ajax command for highlighting elements. + * + * @param {Drupal.Ajax} [ajax] + * An Ajax object. + * @param {object} response + * The Ajax response. + * @param {string} response.selector + * The selector in question. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.viewsHighlight = function (ajax, response, status) { + $('.hilited').removeClass('hilited'); + $(response.selector).addClass('hilited'); + }; + + /** + * Ajax command to set the form submit action in the views modal edit form. + * + * @param {Drupal.Ajax} [ajax] + * An Ajax object. + * @param {object} response + * The Ajax response. Contains .url + * @param {string} [status] + * The XHR status code? + */ + Drupal.AjaxCommands.prototype.viewsSetForm = function (ajax, response, status) { + var $form = $('.js-views-ui-dialog form'); + // Identify the button that was clicked so that .ajaxSubmit() can use it. + // We need to do this for both .click() and .mousedown() since JavaScript + // code might trigger either behavior. + var $submit_buttons = $form.find('input[type=submit].js-form-submit, button.js-form-submit').once('views-ajax-submit'); + $submit_buttons.on('click mousedown', function () { + this.form.clk = this; + }); + $form.once('views-ajax-submit').each(function () { + var $form = $(this); + var element_settings = { + url: response.url, + event: 'submit', + base: $form.attr('id'), + element: this + }; + var ajaxForm = Drupal.ajax(element_settings); + ajaxForm.$form = $form; + }); + }; + + /** + * Ajax command to show certain buttons in the views edit form. + * + * @param {Drupal.Ajax} [ajax] + * An Ajax object. + * @param {object} response + * The Ajax response. + * @param {bool} response.changed + * Whether the state changed for the buttons or not. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.viewsShowButtons = function (ajax, response, status) { + $('div.views-edit-view div.form-actions').removeClass('js-hide'); + if (response.changed) { + $('div.views-edit-view div.view-changed.messages').removeClass('js-hide'); + } + }; + + /** + * Ajax command for triggering preview. + * + * @param {Drupal.Ajax} [ajax] + * An Ajax object. + * @param {object} [response] + * The Ajax response. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.viewsTriggerPreview = function (ajax, response, status) { + if ($('input#edit-displays-live-preview').is(':checked')) { + $('#preview-submit').trigger('click'); + } + }; + + /** + * Ajax command to replace the title of a page. + * + * @param {Drupal.Ajax} [ajax] + * An Ajax object. + * @param {object} response + * The Ajax response. + * @param {string} response.siteName + * The site name. + * @param {string} response.title + * The new page title. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.viewsReplaceTitle = function (ajax, response, status) { + var doc = document; + // For the <title> element, make a best-effort attempt to replace the page + // title and leave the site name alone. If the theme doesn't use the site + // name in the <title> element, this will fail. + var oldTitle = doc.title; + // Escape the site name, in case it has special characters in it, so we can + // use it in our regex. + var escapedSiteName = response.siteName.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + var re = new RegExp('.+ (.) ' + escapedSiteName); + doc.title = oldTitle.replace(re, response.title + ' $1 ' + response.siteName); + + $('h1.page-title').text(response.title); + }; + + /** + * Get rid of irritating tabledrag messages. + * + * @return {Array} + * An array of messages. Always empty array, to get rid of the messages. + */ + Drupal.theme.tableDragChangedWarning = function () { + return []; + }; + + /** + * Trigger preview when the "live preview" checkbox is checked. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to trigger live preview if the live preview option is + * checked. + */ + Drupal.behaviors.livePreview = { + attach: function (context) { + $('input#edit-displays-live-preview', context).once('views-ajax').on('click', function () { + if ($(this).is(':checked')) { + $('#preview-submit').trigger('click'); + } + }); + } + }; + + /** + * Sync preview display. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to sync the preview display when needed. + */ + Drupal.behaviors.syncPreviewDisplay = { + attach: function (context) { + $('#views-tabset a').once('views-ajax').on('click', function () { + var href = $(this).attr('href'); + // Cut of #views-tabset. + var display_id = href.substr(11); + // Set the form element. + $('#views-live-preview #preview-display-id').val(display_id); + }); + } + }; + + /** + * Ajax behaviors for the views_ui module. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches ajax behaviors to the elements with the classes in question. + */ + Drupal.behaviors.viewsAjax = { + collapseReplaced: false, + attach: function (context, settings) { + var base_element_settings = { + event: 'click', + progress: {type: 'fullscreen'} + }; + // Bind AJAX behaviors to all items showing the class. + $('a.views-ajax-link', context).once('views-ajax').each(function () { + var element_settings = base_element_settings; + element_settings.base = $(this).attr('id'); + element_settings.element = this; + // Set the URL to go to the anchor. + if ($(this).attr('href')) { + element_settings.url = $(this).attr('href'); + } + Drupal.ajax(element_settings); + }); + + $('div#views-live-preview a') + .once('views-ajax').each(function () { + // We don't bind to links without a URL. + if (!$(this).attr('href')) { + return true; + } + + var element_settings = base_element_settings; + // Set the URL to go to the anchor. + element_settings.url = $(this).attr('href'); + if (Drupal.Views.getPath(element_settings.url).substring(0, 21) !== 'admin/structure/views') { + return true; + } + + element_settings.wrapper = 'views-preview-wrapper'; + element_settings.method = 'replaceWith'; + element_settings.base = $(this).attr('id'); + element_settings.element = this; + Drupal.ajax(element_settings); + }); + + // Within a live preview, make exposed widget form buttons re-trigger the + // Preview button. + // @todo Revisit this after fixing Views UI to display a Preview outside + // of the main Edit form. + $('div#views-live-preview input[type=submit]') + .once('views-ajax').each(function (event) { + $(this).on('click', function () { + this.form.clk = this; + return true; + }); + var element_settings = base_element_settings; + // Set the URL to go to the anchor. + element_settings.url = $(this.form).attr('action'); + if (Drupal.Views.getPath(element_settings.url).substring(0, 21) !== 'admin/structure/views') { + return true; + } + + element_settings.wrapper = 'views-preview-wrapper'; + element_settings.method = 'replaceWith'; + element_settings.event = 'click'; + element_settings.base = $(this).attr('id'); + element_settings.element = this; + + Drupal.ajax(element_settings); + }); + + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/views_ui/js/ajax.js b/core/modules/views_ui/js/ajax.js index d4ff7642f8fb..d2cf6d7eac76 100644 --- a/core/modules/views_ui/js/ajax.js +++ b/core/modules/views_ui/js/ajax.js @@ -1,44 +1,23 @@ /** - * @file - * Handles AJAX submission and response in Views UI. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/views_ui/js/ajax.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * Ajax command for highlighting elements. - * - * @param {Drupal.Ajax} [ajax] - * An Ajax object. - * @param {object} response - * The Ajax response. - * @param {string} response.selector - * The selector in question. - * @param {number} [status] - * The HTTP status code. - */ Drupal.AjaxCommands.prototype.viewsHighlight = function (ajax, response, status) { $('.hilited').removeClass('hilited'); $(response.selector).addClass('hilited'); }; - /** - * Ajax command to set the form submit action in the views modal edit form. - * - * @param {Drupal.Ajax} [ajax] - * An Ajax object. - * @param {object} response - * The Ajax response. Contains .url - * @param {string} [status] - * The XHR status code? - */ Drupal.AjaxCommands.prototype.viewsSetForm = function (ajax, response, status) { var $form = $('.js-views-ui-dialog form'); - // Identify the button that was clicked so that .ajaxSubmit() can use it. - // We need to do this for both .click() and .mousedown() since JavaScript - // code might trigger either behavior. + var $submit_buttons = $form.find('input[type=submit].js-form-submit, button.js-form-submit').once('views-ajax-submit'); $submit_buttons.on('click mousedown', function () { this.form.clk = this; @@ -56,18 +35,6 @@ }); }; - /** - * Ajax command to show certain buttons in the views edit form. - * - * @param {Drupal.Ajax} [ajax] - * An Ajax object. - * @param {object} response - * The Ajax response. - * @param {bool} response.changed - * Whether the state changed for the buttons or not. - * @param {number} [status] - * The HTTP status code. - */ Drupal.AjaxCommands.prototype.viewsShowButtons = function (ajax, response, status) { $('div.views-edit-view div.form-actions').removeClass('js-hide'); if (response.changed) { @@ -75,44 +42,17 @@ } }; - /** - * Ajax command for triggering preview. - * - * @param {Drupal.Ajax} [ajax] - * An Ajax object. - * @param {object} [response] - * The Ajax response. - * @param {number} [status] - * The HTTP status code. - */ Drupal.AjaxCommands.prototype.viewsTriggerPreview = function (ajax, response, status) { if ($('input#edit-displays-live-preview').is(':checked')) { $('#preview-submit').trigger('click'); } }; - /** - * Ajax command to replace the title of a page. - * - * @param {Drupal.Ajax} [ajax] - * An Ajax object. - * @param {object} response - * The Ajax response. - * @param {string} response.siteName - * The site name. - * @param {string} response.title - * The new page title. - * @param {number} [status] - * The HTTP status code. - */ Drupal.AjaxCommands.prototype.viewsReplaceTitle = function (ajax, response, status) { var doc = document; - // For the <title> element, make a best-effort attempt to replace the page - // title and leave the site name alone. If the theme doesn't use the site - // name in the <title> element, this will fail. + var oldTitle = doc.title; - // Escape the site name, in case it has special characters in it, so we can - // use it in our regex. + var escapedSiteName = response.siteName.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); var re = new RegExp('.+ (.) ' + escapedSiteName); doc.title = oldTitle.replace(re, response.title + ' $1 ' + response.siteName); @@ -120,27 +60,12 @@ $('h1.page-title').text(response.title); }; - /** - * Get rid of irritating tabledrag messages. - * - * @return {Array} - * An array of messages. Always empty array, to get rid of the messages. - */ Drupal.theme.tableDragChangedWarning = function () { return []; }; - /** - * Trigger preview when the "live preview" checkbox is checked. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior to trigger live preview if the live preview option is - * checked. - */ Drupal.behaviors.livePreview = { - attach: function (context) { + attach: function attach(context) { $('input#edit-displays-live-preview', context).once('views-ajax').on('click', function () { if ($(this).is(':checked')) { $('#preview-submit').trigger('click'); @@ -149,101 +74,76 @@ } }; - /** - * Sync preview display. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior to sync the preview display when needed. - */ Drupal.behaviors.syncPreviewDisplay = { - attach: function (context) { + attach: function attach(context) { $('#views-tabset a').once('views-ajax').on('click', function () { var href = $(this).attr('href'); - // Cut of #views-tabset. + var display_id = href.substr(11); - // Set the form element. + $('#views-live-preview #preview-display-id').val(display_id); }); } }; - /** - * Ajax behaviors for the views_ui module. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches ajax behaviors to the elements with the classes in question. - */ Drupal.behaviors.viewsAjax = { collapseReplaced: false, - attach: function (context, settings) { + attach: function attach(context, settings) { var base_element_settings = { event: 'click', - progress: {type: 'fullscreen'} + progress: { type: 'fullscreen' } }; - // Bind AJAX behaviors to all items showing the class. + $('a.views-ajax-link', context).once('views-ajax').each(function () { var element_settings = base_element_settings; element_settings.base = $(this).attr('id'); element_settings.element = this; - // Set the URL to go to the anchor. + if ($(this).attr('href')) { element_settings.url = $(this).attr('href'); } Drupal.ajax(element_settings); }); - $('div#views-live-preview a') - .once('views-ajax').each(function () { - // We don't bind to links without a URL. - if (!$(this).attr('href')) { - return true; - } + $('div#views-live-preview a').once('views-ajax').each(function () { + if (!$(this).attr('href')) { + return true; + } - var element_settings = base_element_settings; - // Set the URL to go to the anchor. - element_settings.url = $(this).attr('href'); - if (Drupal.Views.getPath(element_settings.url).substring(0, 21) !== 'admin/structure/views') { - return true; - } - - element_settings.wrapper = 'views-preview-wrapper'; - element_settings.method = 'replaceWith'; - element_settings.base = $(this).attr('id'); - element_settings.element = this; - Drupal.ajax(element_settings); - }); + var element_settings = base_element_settings; - // Within a live preview, make exposed widget form buttons re-trigger the - // Preview button. - // @todo Revisit this after fixing Views UI to display a Preview outside - // of the main Edit form. - $('div#views-live-preview input[type=submit]') - .once('views-ajax').each(function (event) { - $(this).on('click', function () { - this.form.clk = this; - return true; - }); - var element_settings = base_element_settings; - // Set the URL to go to the anchor. - element_settings.url = $(this.form).attr('action'); - if (Drupal.Views.getPath(element_settings.url).substring(0, 21) !== 'admin/structure/views') { - return true; - } - - element_settings.wrapper = 'views-preview-wrapper'; - element_settings.method = 'replaceWith'; - element_settings.event = 'click'; - element_settings.base = $(this).attr('id'); - element_settings.element = this; - - Drupal.ajax(element_settings); + element_settings.url = $(this).attr('href'); + if (Drupal.Views.getPath(element_settings.url).substring(0, 21) !== 'admin/structure/views') { + return true; + } + + element_settings.wrapper = 'views-preview-wrapper'; + element_settings.method = 'replaceWith'; + element_settings.base = $(this).attr('id'); + element_settings.element = this; + Drupal.ajax(element_settings); + }); + + $('div#views-live-preview input[type=submit]').once('views-ajax').each(function (event) { + $(this).on('click', function () { + this.form.clk = this; + return true; }); + var element_settings = base_element_settings; + + element_settings.url = $(this.form).attr('action'); + if (Drupal.Views.getPath(element_settings.url).substring(0, 21) !== 'admin/structure/views') { + return true; + } + element_settings.wrapper = 'views-preview-wrapper'; + element_settings.method = 'replaceWith'; + element_settings.event = 'click'; + element_settings.base = $(this).attr('id'); + element_settings.element = this; + + Drupal.ajax(element_settings); + }); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/views_ui/js/dialog.views.es6.js b/core/modules/views_ui/js/dialog.views.es6.js new file mode 100644 index 000000000000..3f40b4ad5b9e --- /dev/null +++ b/core/modules/views_ui/js/dialog.views.es6.js @@ -0,0 +1,58 @@ +/** + * @file + * Views dialog behaviors. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + function handleDialogResize(e) { + var $modal = $(e.currentTarget); + var $viewsOverride = $modal.find('[data-drupal-views-offset]'); + var $scroll = $modal.find('[data-drupal-views-scroll]'); + var offset = 0; + var modalHeight; + if ($scroll.length) { + // Add a class to do some styles adjustments. + $modal.closest('.views-ui-dialog').addClass('views-ui-dialog-scroll'); + // Let scroll element take all the height available. + $scroll.css({overflow: 'visible', height: 'auto'}); + modalHeight = $modal.height(); + $viewsOverride.each(function () { offset += $(this).outerHeight(); }); + + // Take internal padding into account. + var scrollOffset = $scroll.outerHeight() - $scroll.height(); + $scroll.height(modalHeight - offset - scrollOffset); + // Reset scrolling properties. + $modal.css('overflow', 'hidden'); + $scroll.css('overflow', 'auto'); + } + } + + /** + * Functionality for views modals. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches modal functionality for views. + * @prop {Drupal~behaviorDetach} detach + * Detaches the modal functionality. + */ + Drupal.behaviors.viewsModalContent = { + attach: function (context) { + $('body').once('viewsDialog').on('dialogContentResize.viewsDialog', '.ui-dialog-content', handleDialogResize); + // When expanding details, make sure the modal is resized. + $(context).find('.scroll').once('detailsUpdate').on('click', 'summary', function (e) { + $(e.currentTarget).trigger('dialogContentResize'); + }); + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + $('body').removeOnce('viewsDialog').off('.viewsDialog'); + } + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/views_ui/js/dialog.views.js b/core/modules/views_ui/js/dialog.views.js index 3f40b4ad5b9e..a4c20bb33a20 100644 --- a/core/modules/views_ui/js/dialog.views.js +++ b/core/modules/views_ui/js/dialog.views.js @@ -1,7 +1,10 @@ /** - * @file - * Views dialog behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/views_ui/js/dialog.views.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { @@ -14,45 +17,34 @@ var offset = 0; var modalHeight; if ($scroll.length) { - // Add a class to do some styles adjustments. $modal.closest('.views-ui-dialog').addClass('views-ui-dialog-scroll'); - // Let scroll element take all the height available. - $scroll.css({overflow: 'visible', height: 'auto'}); + + $scroll.css({ overflow: 'visible', height: 'auto' }); modalHeight = $modal.height(); - $viewsOverride.each(function () { offset += $(this).outerHeight(); }); + $viewsOverride.each(function () { + offset += $(this).outerHeight(); + }); - // Take internal padding into account. var scrollOffset = $scroll.outerHeight() - $scroll.height(); $scroll.height(modalHeight - offset - scrollOffset); - // Reset scrolling properties. + $modal.css('overflow', 'hidden'); $scroll.css('overflow', 'auto'); } } - /** - * Functionality for views modals. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches modal functionality for views. - * @prop {Drupal~behaviorDetach} detach - * Detaches the modal functionality. - */ Drupal.behaviors.viewsModalContent = { - attach: function (context) { + attach: function attach(context) { $('body').once('viewsDialog').on('dialogContentResize.viewsDialog', '.ui-dialog-content', handleDialogResize); - // When expanding details, make sure the modal is resized. + $(context).find('.scroll').once('detailsUpdate').on('click', 'summary', function (e) { $(e.currentTarget).trigger('dialogContentResize'); }); }, - detach: function (context, settings, trigger) { + detach: function detach(context, settings, trigger) { if (trigger === 'unload') { $('body').removeOnce('viewsDialog').off('.viewsDialog'); } } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/views_ui/js/views-admin.es6.js b/core/modules/views_ui/js/views-admin.es6.js new file mode 100644 index 000000000000..0fda80874895 --- /dev/null +++ b/core/modules/views_ui/js/views-admin.es6.js @@ -0,0 +1,1192 @@ +/** + * @file + * Some basic behaviors and utility functions for Views UI. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * @namespace + */ + Drupal.viewsUi = {}; + + /** + * Improve the user experience of the views edit interface. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches toggling of SQL rewrite warning on the corresponding checkbox. + */ + Drupal.behaviors.viewsUiEditView = { + attach: function () { + // Only show the SQL rewrite warning when the user has chosen the + // corresponding checkbox. + $('[data-drupal-selector="edit-query-options-disable-sql-rewrite"]').on('click', function () { + $('.sql-rewrite-warning').toggleClass('js-hide'); + }); + } + }; + + /** + * In the add view wizard, use the view name to prepopulate form fields such + * as page title and menu link. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for prepopulating page title and menu links, based on + * view name. + */ + Drupal.behaviors.viewsUiAddView = { + attach: function (context) { + var $context = $(context); + // Set up regular expressions to allow only numbers, letters, and dashes. + var exclude = new RegExp('[^a-z0-9\\-]+', 'g'); + var replace = '-'; + var suffix; + + // The page title, block title, and menu link fields can all be + // prepopulated with the view name - no regular expression needed. + var $fields = $context.find('[id^="edit-page-title"], [id^="edit-block-title"], [id^="edit-page-link-properties-title"]'); + if ($fields.length) { + if (!this.fieldsFiller) { + this.fieldsFiller = new Drupal.viewsUi.FormFieldFiller($fields); + } + else { + // After an AJAX response, this.fieldsFiller will still have event + // handlers bound to the old version of the form fields (which don't + // exist anymore). The event handlers need to be unbound and then + // rebound to the new markup. Note that jQuery.live is difficult to + // make work in this case because the IDs of the form fields change + // on every AJAX response. + this.fieldsFiller.rebind($fields); + } + } + + // Prepopulate the path field with a URLified version of the view name. + var $pathField = $context.find('[id^="edit-page-path"]'); + if ($pathField.length) { + if (!this.pathFiller) { + this.pathFiller = new Drupal.viewsUi.FormFieldFiller($pathField, exclude, replace); + } + else { + this.pathFiller.rebind($pathField); + } + } + + // Populate the RSS feed field with a URLified version of the view name, + // and an .xml suffix (to make it unique). + var $feedField = $context.find('[id^="edit-page-feed-properties-path"]'); + if ($feedField.length) { + if (!this.feedFiller) { + suffix = '.xml'; + this.feedFiller = new Drupal.viewsUi.FormFieldFiller($feedField, exclude, replace, suffix); + } + else { + this.feedFiller.rebind($feedField); + } + } + } + }; + + /** + * Constructor for the {@link Drupal.viewsUi.FormFieldFiller} object. + * + * Prepopulates a form field based on the view name. + * + * @constructor + * + * @param {jQuery} $target + * A jQuery object representing the form field or fields to prepopulate. + * @param {bool} [exclude=false] + * A regular expression representing characters to exclude from + * the target field. + * @param {string} [replace=''] + * A string to use as the replacement value for disallowed characters. + * @param {string} [suffix=''] + * A suffix to append at the end of the target field content. + */ + Drupal.viewsUi.FormFieldFiller = function ($target, exclude, replace, suffix) { + + /** + * + * @type {jQuery} + */ + this.source = $('#edit-label'); + + /** + * + * @type {jQuery} + */ + this.target = $target; + + /** + * + * @type {bool} + */ + this.exclude = exclude || false; + + /** + * + * @type {string} + */ + this.replace = replace || ''; + + /** + * + * @type {string} + */ + this.suffix = suffix || ''; + + // Create bound versions of this instance's object methods to use as event + // handlers. This will let us easily unbind those specific handlers later + // on. NOTE: jQuery.proxy will not work for this because it assumes we want + // only one bound version of an object method, whereas we need one version + // per object instance. + var self = this; + + /** + * Populate the target form field with the altered source field value. + * + * @return {*} + * The result of the _populate call, which should be undefined. + */ + this.populate = function () { return self._populate.call(self); }; + + /** + * Stop prepopulating the form fields. + * + * @return {*} + * The result of the _unbind call, which should be undefined. + */ + this.unbind = function () { return self._unbind.call(self); }; + + this.bind(); + // Object constructor; no return value. + }; + + $.extend(Drupal.viewsUi.FormFieldFiller.prototype, /** @lends Drupal.viewsUi.FormFieldFiller# */{ + + /** + * Bind the form-filling behavior. + */ + bind: function () { + this.unbind(); + // Populate the form field when the source changes. + this.source.on('keyup.viewsUi change.viewsUi', this.populate); + // Quit populating the field as soon as it gets focus. + this.target.on('focus.viewsUi', this.unbind); + }, + + /** + * Get the source form field value as altered by the passed-in parameters. + * + * @return {string} + * The source form field value. + */ + getTransliterated: function () { + var from = this.source.val(); + if (this.exclude) { + from = from.toLowerCase().replace(this.exclude, this.replace); + } + return from; + }, + + /** + * Populate the target form field with the altered source field value. + */ + _populate: function () { + var transliterated = this.getTransliterated(); + var suffix = this.suffix; + this.target.each(function (i) { + // Ensure that the maxlength is not exceeded by prepopulating the field. + var maxlength = $(this).attr('maxlength') - suffix.length; + $(this).val(transliterated.substr(0, maxlength) + suffix); + }); + }, + + /** + * Stop prepopulating the form fields. + */ + _unbind: function () { + this.source.off('keyup.viewsUi change.viewsUi', this.populate); + this.target.off('focus.viewsUi', this.unbind); + }, + + /** + * Bind event handlers to new form fields, after they're replaced via Ajax. + * + * @param {jQuery} $fields + * Fields to rebind functionality to. + */ + rebind: function ($fields) { + this.target = $fields; + this.bind(); + } + }); + + /** + * Adds functionality for the add item form. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the functionality in {@link Drupal.viewsUi.AddItemForm} to the + * forms in question. + */ + Drupal.behaviors.addItemForm = { + attach: function (context) { + var $context = $(context); + var $form = $context; + // The add handler form may have an id of views-ui-add-handler-form--n. + if (!$context.is('form[id^="views-ui-add-handler-form"]')) { + $form = $context.find('form[id^="views-ui-add-handler-form"]'); + } + if ($form.once('views-ui-add-handler-form').length) { + // If we we have an unprocessed views-ui-add-handler-form, let's + // instantiate. + new Drupal.viewsUi.AddItemForm($form); + } + } + }; + + /** + * Constructs a new AddItemForm. + * + * @constructor + * + * @param {jQuery} $form + * The form element used. + */ + Drupal.viewsUi.AddItemForm = function ($form) { + + /** + * + * @type {jQuery} + */ + this.$form = $form; + this.$form.find('.views-filterable-options :checkbox').on('click', $.proxy(this.handleCheck, this)); + + /** + * Find the wrapper of the displayed text. + */ + this.$selected_div = this.$form.find('.views-selected-options').parent(); + this.$selected_div.hide(); + + /** + * + * @type {Array} + */ + this.checkedItems = []; + }; + + /** + * Handles a checkbox check. + * + * @param {jQuery.Event} event + * The event triggered. + */ + Drupal.viewsUi.AddItemForm.prototype.handleCheck = function (event) { + var $target = $(event.target); + var label = $.trim($target.closest('td').next().html()); + // Add/remove the checked item to the list. + if ($target.is(':checked')) { + this.$selected_div.show().css('display', 'block'); + this.checkedItems.push(label); + } + else { + var position = $.inArray(label, this.checkedItems); + // Delete the item from the list and make sure that the list doesn't have + // undefined items left. + for (var i = 0; i < this.checkedItems.length; i++) { + if (i === position) { + this.checkedItems.splice(i, 1); + i--; + break; + } + } + // Hide it again if none item is selected. + if (this.checkedItems.length === 0) { + this.$selected_div.hide(); + } + } + this.refreshCheckedItems(); + }; + + /** + * Refresh the display of the checked items. + */ + Drupal.viewsUi.AddItemForm.prototype.refreshCheckedItems = function () { + // Perhaps we should precache the text div, too. + this.$selected_div.find('.views-selected-options') + .html(this.checkedItems.join(', ')) + .trigger('dialogContentResize'); + }; + + /** + * The input field items that add displays must be rendered as `<input>` + * elements. The following behavior detaches the `<input>` elements from the + * DOM, wraps them in an unordered list, then appends them to the list of + * tabs. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Fixes the input elements needed. + */ + Drupal.behaviors.viewsUiRenderAddViewButton = { + attach: function (context) { + // Build the add display menu and pull the display input buttons into it. + var $menu = $(context).find('#views-display-menu-tabs').once('views-ui-render-add-view-button'); + if (!$menu.length) { + return; + } + + var $addDisplayDropdown = $('<li class="add"><a href="#"><span class="icon add"></span>' + Drupal.t('Add') + '</a><ul class="action-list" style="display:none;"></ul></li>'); + var $displayButtons = $menu.nextAll('input.add-display').detach(); + $displayButtons.appendTo($addDisplayDropdown.find('.action-list')).wrap('<li>') + .parent().eq(0).addClass('first').end().eq(-1).addClass('last'); + // Remove the 'Add ' prefix from the button labels since they're being + // placed in an 'Add' dropdown. @todo This assumes English, but so does + // $addDisplayDropdown above. Add support for translation. + $displayButtons.each(function () { + var label = $(this).val(); + if (label.substr(0, 4) === 'Add ') { + $(this).val(label.substr(4)); + } + }); + $addDisplayDropdown.appendTo($menu); + + // Add the click handler for the add display button. + $menu.find('li.add > a').on('click', function (event) { + event.preventDefault(); + var $trigger = $(this); + Drupal.behaviors.viewsUiRenderAddViewButton.toggleMenu($trigger); + }); + // Add a mouseleave handler to close the dropdown when the user mouses + // away from the item. We use mouseleave instead of mouseout because + // the user is going to trigger mouseout when she moves from the trigger + // link to the sub menu items. + // We use the live binder because the open class on this item will be + // toggled on and off and we want the handler to take effect in the cases + // that the class is present, but not when it isn't. + $('li.add', $menu).on('mouseleave', function (event) { + var $this = $(this); + var $trigger = $this.children('a[href="#"]'); + if ($this.children('.action-list').is(':visible')) { + Drupal.behaviors.viewsUiRenderAddViewButton.toggleMenu($trigger); + } + }); + } + }; + + /** + * Toggle menu visibility. + * + * @param {jQuery} $trigger + * The element where the toggle was triggered. + * + * + * @note [@jessebeach] I feel like the following should be a more generic + * function and not written specifically for this UI, but I'm not sure + * where to put it. + */ + Drupal.behaviors.viewsUiRenderAddViewButton.toggleMenu = function ($trigger) { + $trigger.parent().toggleClass('open'); + $trigger.next().slideToggle('fast'); + }; + + /** + * Add search options to the views ui. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches {@link Drupal.viewsUi.OptionsSearch} to the views ui filter + * options. + */ + Drupal.behaviors.viewsUiSearchOptions = { + attach: function (context) { + var $context = $(context); + var $form = $context; + // The add handler form may have an id of views-ui-add-handler-form--n. + if (!$context.is('form[id^="views-ui-add-handler-form"]')) { + $form = $context.find('form[id^="views-ui-add-handler-form"]'); + } + // Make sure we don't add more than one event handler to the same form. + if ($form.once('views-ui-filter-options').length) { + new Drupal.viewsUi.OptionsSearch($form); + } + } + }; + + /** + * Constructor for the viewsUi.OptionsSearch object. + * + * The OptionsSearch object filters the available options on a form according + * to the user's search term. Typing in "taxonomy" will show only those + * options containing "taxonomy" in their label. + * + * @constructor + * + * @param {jQuery} $form + * The form element. + */ + Drupal.viewsUi.OptionsSearch = function ($form) { + + /** + * + * @type {jQuery} + */ + this.$form = $form; + + // Click on the title checks the box. + this.$form.on('click', 'td.title', function (event) { + var $target = $(event.currentTarget); + $target.closest('tr').find('input').trigger('click'); + }); + + var searchBoxSelector = '[data-drupal-selector="edit-override-controls-options-search"]'; + var controlGroupSelector = '[data-drupal-selector="edit-override-controls-group"]'; + this.$form.on('formUpdated', searchBoxSelector + ',' + controlGroupSelector, $.proxy(this.handleFilter, this)); + + this.$searchBox = this.$form.find(searchBoxSelector); + this.$controlGroup = this.$form.find(controlGroupSelector); + + /** + * Get a list of option labels and their corresponding divs and maintain it + * in memory, so we have as little overhead as possible at keyup time. + */ + this.options = this.getOptions(this.$form.find('.filterable-option')); + + // Trap the ENTER key in the search box so that it doesn't submit the form. + this.$searchBox.on('keypress', function (event) { + if (event.which === 13) { + event.preventDefault(); + } + }); + }; + + $.extend(Drupal.viewsUi.OptionsSearch.prototype, /** @lends Drupal.viewsUi.OptionsSearch# */{ + + /** + * Assemble a list of all the filterable options on the form. + * + * @param {jQuery} $allOptions + * A jQuery object representing the rows of filterable options to be + * shown and hidden depending on the user's search terms. + * + * @return {Array} + * An array of all the filterable options. + */ + getOptions: function ($allOptions) { + var $title; + var $description; + var $option; + var options = []; + var length = $allOptions.length; + for (var i = 0; i < length; i++) { + $option = $($allOptions[i]); + $title = $option.find('.title'); + $description = $option.find('.description'); + options[i] = { + // Search on the lowercase version of the title text + description. + searchText: $title.text().toLowerCase() + ' ' + $description.text().toLowerCase(), + // Maintain a reference to the jQuery object for each row, so we don't + // have to create a new object inside the performance-sensitive keyup + // handler. + $div: $option + }; + } + return options; + }, + + /** + * Filter handler for the search box and type select that hides or shows the relevant + * options. + * + * @param {jQuery.Event} event + * The formUpdated event. + */ + handleFilter: function (event) { + // Determine the user's search query. The search text has been converted + // to lowercase. + var search = this.$searchBox.val().toLowerCase(); + var words = search.split(' '); + // Get selected Group + var group = this.$controlGroup.val(); + + // Search through the search texts in the form for matching text. + this.options.forEach(function (option) { + function hasWord(word) { + return option.searchText.indexOf(word) !== -1; + } + + var found = true; + // Each word in the search string has to match the item in order for the + // item to be shown. + if (search) { + found = words.every(hasWord); + } + if (found && group !== 'all') { + found = option.$div.hasClass(group); + } + + option.$div.toggle(found); + }); + + // Adapt dialog to content size. + $(event.target).trigger('dialogContentResize'); + } + }); + + /** + * Preview functionality in the views edit form. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the preview functionality to the view edit form. + */ + Drupal.behaviors.viewsUiPreview = { + attach: function (context) { + // Only act on the edit view form. + var $contextualFiltersBucket = $(context).find('.views-display-column .views-ui-display-tab-bucket.argument'); + if ($contextualFiltersBucket.length === 0) { + return; + } + + // If the display has no contextual filters, hide the form where you + // enter the contextual filters for the live preview. If it has contextual + // filters, show the form. + var $contextualFilters = $contextualFiltersBucket.find('.views-display-setting a'); + if ($contextualFilters.length) { + $('#preview-args').parent().show(); + } + else { + $('#preview-args').parent().hide(); + } + + // Executes an initial preview. + if ($('#edit-displays-live-preview').once('edit-displays-live-preview').is(':checked')) { + $('#preview-submit').once('edit-displays-live-preview').trigger('click'); + } + } + }; + + /** + * Rearranges the filters. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attach handlers to make it possible to rearange the filters in the form + * in question. + * @see Drupal.viewsUi.RearrangeFilterHandler + */ + Drupal.behaviors.viewsUiRearrangeFilter = { + attach: function (context) { + // Only act on the rearrange filter form. + if (typeof Drupal.tableDrag === 'undefined' || typeof Drupal.tableDrag['views-rearrange-filters'] === 'undefined') { + return; + } + var $context = $(context); + var $table = $context.find('#views-rearrange-filters').once('views-rearrange-filters'); + var $operator = $context.find('.js-form-item-filter-groups-operator').once('views-rearrange-filters'); + if ($table.length) { + new Drupal.viewsUi.RearrangeFilterHandler($table, $operator); + } + } + }; + + /** + * Improve the UI of the rearrange filters dialog box. + * + * @constructor + * + * @param {jQuery} $table + * The table in the filter form. + * @param {jQuery} $operator + * The filter groups operator element. + */ + Drupal.viewsUi.RearrangeFilterHandler = function ($table, $operator) { + + /** + * Keep a reference to the `<table>` being altered and to the div containing + * the filter groups operator dropdown (if it exists). + */ + this.table = $table; + + /** + * + * @type {jQuery} + */ + this.operator = $operator; + + /** + * + * @type {bool} + */ + this.hasGroupOperator = this.operator.length > 0; + + /** + * Keep a reference to all draggable rows within the table. + * + * @type {jQuery} + */ + this.draggableRows = $table.find('.draggable'); + + /** + * Keep a reference to the buttons for adding and removing filter groups. + * + * @type {jQuery} + */ + this.addGroupButton = $('input#views-add-group'); + + /** + * @type {jQuery} + */ + this.removeGroupButtons = $table.find('input.views-remove-group'); + + // Add links that duplicate the functionality of the (hidden) add and remove + // buttons. + this.insertAddRemoveFilterGroupLinks(); + + // When there is a filter groups operator dropdown on the page, create + // duplicates of the dropdown between each pair of filter groups. + if (this.hasGroupOperator) { + + /** + * @type {jQuery} + */ + this.dropdowns = this.duplicateGroupsOperator(); + this.syncGroupsOperators(); + } + + // Add methods to the tableDrag instance to account for operator cells + // (which span multiple rows), the operator labels next to each filter + // (e.g., "And" or "Or"), the filter groups, and other special aspects of + // this tableDrag instance. + this.modifyTableDrag(); + + // Initialize the operator labels (e.g., "And" or "Or") that are displayed + // next to the filters in each group, and bind a handler so that they change + // based on the values of the operator dropdown within that group. + this.redrawOperatorLabels(); + $table.find('.views-group-title select') + .once('views-rearrange-filter-handler') + .on('change.views-rearrange-filter-handler', $.proxy(this, 'redrawOperatorLabels')); + + // Bind handlers so that when a "Remove" link is clicked, we: + // - Update the rowspans of cells containing an operator dropdown (since + // they need to change to reflect the number of rows in each group). + // - Redraw the operator labels next to the filters in the group (since the + // filter that is currently displayed last in each group is not supposed + // to have a label display next to it). + $table.find('a.views-groups-remove-link') + .once('views-rearrange-filter-handler') + .on('click.views-rearrange-filter-handler', $.proxy(this, 'updateRowspans')) + .on('click.views-rearrange-filter-handler', $.proxy(this, 'redrawOperatorLabels')); + }; + + $.extend(Drupal.viewsUi.RearrangeFilterHandler.prototype, /** @lends Drupal.viewsUi.RearrangeFilterHandler# */{ + + /** + * Insert links that allow filter groups to be added and removed. + */ + insertAddRemoveFilterGroupLinks: function () { + + // Insert a link for adding a new group at the top of the page, and make + // it match the action link styling used in a typical page.html.twig. + // Since Drupal does not provide a theme function for this markup this is + // the best we can do. + $('<ul class="action-links"><li><a id="views-add-group-link" href="#">' + this.addGroupButton.val() + '</a></li></ul>') + .prependTo(this.table.parent()) + // When the link is clicked, dynamically click the hidden form button + // for adding a new filter group. + .once('views-rearrange-filter-handler') + .find('#views-add-group-link') + .on('click.views-rearrange-filter-handler', $.proxy(this, 'clickAddGroupButton')); + + // Find each (visually hidden) button for removing a filter group and + // insert a link next to it. + var length = this.removeGroupButtons.length; + var i; + for (i = 0; i < length; i++) { + var $removeGroupButton = $(this.removeGroupButtons[i]); + var buttonId = $removeGroupButton.attr('id'); + $('<a href="#" class="views-remove-group-link">' + Drupal.t('Remove group') + '</a>') + .insertBefore($removeGroupButton) + // When the link is clicked, dynamically click the corresponding form + // button. + .once('views-rearrange-filter-handler') + .on('click.views-rearrange-filter-handler', {buttonId: buttonId}, $.proxy(this, 'clickRemoveGroupButton')); + } + }, + + /** + * Dynamically click the button that adds a new filter group. + * + * @param {jQuery.Event} event + * The event triggered. + */ + clickAddGroupButton: function (event) { + this.addGroupButton.trigger('mousedown'); + event.preventDefault(); + }, + + /** + * Dynamically click a button for removing a filter group. + * + * @param {jQuery.Event} event + * Event being triggered, with event.data.buttonId set to the ID of the + * form button that should be clicked. + */ + clickRemoveGroupButton: function (event) { + this.table.find('#' + event.data.buttonId).trigger('mousedown'); + event.preventDefault(); + }, + + /** + * Move the groups operator so that it's between the first two groups, and + * duplicate it between any subsequent groups. + * + * @return {jQuery} + * An operator element. + */ + duplicateGroupsOperator: function () { + var dropdowns; + var newRow; + var titleRow; + + var titleRows = $('tr.views-group-title').once('duplicateGroupsOperator'); + + if (!titleRows.length) { + return this.operator; + } + + // Get rid of the explanatory text around the operator; its placement is + // explanatory enough. + this.operator.find('label').add('div.description').addClass('visually-hidden'); + this.operator.find('select').addClass('form-select'); + + // Keep a list of the operator dropdowns, so we can sync their behavior + // later. + dropdowns = this.operator; + + // Move the operator to a new row just above the second group. + titleRow = $('tr#views-group-title-2'); + newRow = $('<tr class="filter-group-operator-row"><td colspan="5"></td></tr>'); + newRow.find('td').append(this.operator); + newRow.insertBefore(titleRow); + var length = titleRows.length; + // Starting with the third group, copy the operator to a new row above the + // group title. + for (var i = 2; i < length; i++) { + titleRow = $(titleRows[i]); + // Make a copy of the operator dropdown and put it in a new table row. + var fakeOperator = this.operator.clone(); + fakeOperator.attr('id', ''); + newRow = $('<tr class="filter-group-operator-row"><td colspan="5"></td></tr>'); + newRow.find('td').append(fakeOperator); + newRow.insertBefore(titleRow); + dropdowns.add(fakeOperator); + } + + return dropdowns; + }, + + /** + * Make the duplicated groups operators change in sync with each other. + */ + syncGroupsOperators: function () { + if (this.dropdowns.length < 2) { + // We only have one dropdown (or none at all), so there's nothing to + // sync. + return; + } + + this.dropdowns.on('change', $.proxy(this, 'operatorChangeHandler')); + }, + + /** + * Click handler for the operators that appear between filter groups. + * + * Forces all operator dropdowns to have the same value. + * + * @param {jQuery.Event} event + * The event triggered. + */ + operatorChangeHandler: function (event) { + var $target = $(event.target); + var operators = this.dropdowns.find('select').not($target); + + // Change the other operators to match this new value. + operators.val($target.val()); + }, + + /** + * @method + */ + modifyTableDrag: function () { + var tableDrag = Drupal.tableDrag['views-rearrange-filters']; + var filterHandler = this; + + /** + * Override the row.onSwap method from tabledrag.js. + * + * When a row is dragged to another place in the table, several things + * need to occur. + * - The row needs to be moved so that it's within one of the filter + * groups. + * - The operator cells that span multiple rows need their rowspan + * attributes updated to reflect the number of rows in each group. + * - The operator labels that are displayed next to each filter need to + * be redrawn, to account for the row's new location. + */ + tableDrag.row.prototype.onSwap = function () { + if (filterHandler.hasGroupOperator) { + // Make sure the row that just got moved (this.group) is inside one + // of the filter groups (i.e. below an empty marker row or a + // draggable). If it isn't, move it down one. + var thisRow = $(this.group); + var previousRow = thisRow.prev('tr'); + if (previousRow.length && !previousRow.hasClass('group-message') && !previousRow.hasClass('draggable')) { + // Move the dragged row down one. + var next = thisRow.next(); + if (next.is('tr')) { + this.swap('after', next); + } + } + filterHandler.updateRowspans(); + } + // Redraw the operator labels that are displayed next to each filter, to + // account for the row's new location. + filterHandler.redrawOperatorLabels(); + }; + + /** + * Override the onDrop method from tabledrag.js. + */ + tableDrag.onDrop = function () { + // If the tabledrag change marker (i.e., the "*") has been inserted + // inside a row after the operator label (i.e., "And" or "Or") + // rearrange the items so the operator label continues to appear last. + var changeMarker = $(this.oldRowElement).find('.tabledrag-changed'); + if (changeMarker.length) { + // Search for occurrences of the operator label before the change + // marker, and reverse them. + var operatorLabel = changeMarker.prevAll('.views-operator-label'); + if (operatorLabel.length) { + operatorLabel.insertAfter(changeMarker); + } + } + + // Make sure the "group" dropdown is properly updated when rows are + // dragged into an empty filter group. This is borrowed heavily from + // the block.js implementation of tableDrag.onDrop(). + var groupRow = $(this.rowObject.element).prevAll('tr.group-message').get(0); + var groupName = groupRow.className.replace(/([^ ]+[ ]+)*group-([^ ]+)-message([ ]+[^ ]+)*/, '$2'); + var groupField = $('select.views-group-select', this.rowObject.element); + if ($(this.rowObject.element).prev('tr').is('.group-message') && !groupField.is('.views-group-select-' + groupName)) { + var oldGroupName = groupField.attr('class').replace(/([^ ]+[ ]+)*views-group-select-([^ ]+)([ ]+[^ ]+)*/, '$2'); + groupField.removeClass('views-group-select-' + oldGroupName).addClass('views-group-select-' + groupName); + groupField.val(groupName); + } + }; + }, + + /** + * Redraw the operator labels that are displayed next to each filter. + */ + redrawOperatorLabels: function () { + for (var i = 0; i < this.draggableRows.length; i++) { + // Within the row, the operator labels are displayed inside the first + // table cell (next to the filter name). + var $draggableRow = $(this.draggableRows[i]); + var $firstCell = $draggableRow.find('td').eq(0); + if ($firstCell.length) { + // The value of the operator label ("And" or "Or") is taken from the + // first operator dropdown we encounter, going backwards from the + // current row. This dropdown is the one associated with the current + // row's filter group. + var operatorValue = $draggableRow.prevAll('.views-group-title').find('option:selected').html(); + var operatorLabel = '<span class="views-operator-label">' + operatorValue + '</span>'; + // If the next visible row after this one is a draggable filter row, + // display the operator label next to the current row. (Checking for + // visibility is necessary here since the "Remove" links hide the + // removed row but don't actually remove it from the document). + var $nextRow = $draggableRow.nextAll(':visible').eq(0); + var $existingOperatorLabel = $firstCell.find('.views-operator-label'); + if ($nextRow.hasClass('draggable')) { + // If an operator label was already there, replace it with the new + // one. + if ($existingOperatorLabel.length) { + $existingOperatorLabel.replaceWith(operatorLabel); + } + // Otherwise, append the operator label to the end of the table + // cell. + else { + $firstCell.append(operatorLabel); + } + } + // If the next row doesn't contain a filter, then this is the last row + // in the group. We don't want to display the operator there (since + // operators should only display between two related filters, e.g. + // "filter1 AND filter2 AND filter3"). So we remove any existing label + // that this row has. + else { + $existingOperatorLabel.remove(); + } + } + } + }, + + /** + * Update the rowspan attribute of each cell containing an operator + * dropdown. + */ + updateRowspans: function () { + var $row; + var $currentEmptyRow; + var draggableCount; + var $operatorCell; + var rows = $(this.table).find('tr'); + var length = rows.length; + for (var i = 0; i < length; i++) { + $row = $(rows[i]); + if ($row.hasClass('views-group-title')) { + // This row is a title row. + // Keep a reference to the cell containing the dropdown operator. + $operatorCell = $row.find('td.group-operator'); + // Assume this filter group is empty, until we find otherwise. + draggableCount = 0; + $currentEmptyRow = $row.next('tr'); + $currentEmptyRow.removeClass('group-populated').addClass('group-empty'); + // The cell with the dropdown operator should span the title row and + // the "this group is empty" row. + $operatorCell.attr('rowspan', 2); + } + else if ($row.hasClass('draggable') && $row.is(':visible')) { + // We've found a visible filter row, so we now know the group isn't + // empty. + draggableCount++; + $currentEmptyRow.removeClass('group-empty').addClass('group-populated'); + // The operator cell should span all draggable rows, plus the title. + $operatorCell.attr('rowspan', draggableCount + 1); + } + } + } + }); + + /** + * Add a select all checkbox, which checks each checkbox at once. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches select all functionality to the views filter form. + */ + Drupal.behaviors.viewsFilterConfigSelectAll = { + attach: function (context) { + var $context = $(context); + + var $selectAll = $context.find('.js-form-item-options-value-all').once('filterConfigSelectAll'); + var $selectAllCheckbox = $selectAll.find('input[type=checkbox]'); + var $checkboxes = $selectAll.closest('.form-checkboxes').find('.js-form-type-checkbox:not(.js-form-item-options-value-all) input[type="checkbox"]'); + + if ($selectAll.length) { + // Show the select all checkbox. + $selectAll.show(); + $selectAllCheckbox.on('click', function () { + // Update all checkbox beside the select all checkbox. + $checkboxes.prop('checked', $(this).is(':checked')); + }); + + // Uncheck the select all checkbox if any of the others are unchecked. + $checkboxes.on('click', function () { + if ($(this).is('checked') === false) { + $selectAllCheckbox.prop('checked', false); + } + }); + } + } + }; + + /** + * Remove icon class from elements that are themed as buttons or dropbuttons. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Removes the icon class from certain views elements. + */ + Drupal.behaviors.viewsRemoveIconClass = { + attach: function (context) { + $(context).find('.dropbutton').once('dropbutton-icon').find('.icon').removeClass('icon'); + } + }; + + /** + * Change "Expose filter" buttons into checkboxes. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Changes buttons into checkboxes via {@link Drupal.viewsUi.Checkboxifier}. + */ + Drupal.behaviors.viewsUiCheckboxify = { + attach: function (context, settings) { + var $buttons = $('[data-drupal-selector="edit-options-expose-button-button"], [data-drupal-selector="edit-options-group-button-button"]').once('views-ui-checkboxify'); + var length = $buttons.length; + var i; + for (i = 0; i < length; i++) { + new Drupal.viewsUi.Checkboxifier($buttons[i]); + } + } + }; + + /** + * Change the default widget to select the default group according to the + * selected widget for the exposed group. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Changes the default widget based on user input. + */ + Drupal.behaviors.viewsUiChangeDefaultWidget = { + attach: function (context) { + var $context = $(context); + + function changeDefaultWidget(event) { + if ($(event.target).prop('checked')) { + $context.find('input.default-radios').parent().hide(); + $context.find('td.any-default-radios-row').parent().hide(); + $context.find('input.default-checkboxes').parent().show(); + } + else { + $context.find('input.default-checkboxes').parent().hide(); + $context.find('td.any-default-radios-row').parent().show(); + $context.find('input.default-radios').parent().show(); + } + } + + // Update on widget change. + $context.find('input[name="options[group_info][multiple]"]') + .on('change', changeDefaultWidget) + // Update the first time the form is rendered. + .trigger('change'); + } + }; + + /** + * Attaches expose filter button to a checkbox that triggers its click event. + * + * @constructor + * + * @param {HTMLElement} button + * The DOM object representing the button to be checkboxified. + */ + Drupal.viewsUi.Checkboxifier = function (button) { + this.$button = $(button); + this.$parent = this.$button.parent('div.views-expose, div.views-grouped'); + this.$input = this.$parent.find('input:checkbox, input:radio'); + // Hide the button and its description. + this.$button.hide(); + this.$parent.find('.exposed-description, .grouped-description').hide(); + + this.$input.on('click', $.proxy(this, 'clickHandler')); + + }; + + /** + * When the checkbox is checked or unchecked, simulate a button press. + * + * @param {jQuery.Event} e + * The event triggered. + */ + Drupal.viewsUi.Checkboxifier.prototype.clickHandler = function (e) { + this.$button + .trigger('click') + .trigger('submit'); + }; + + /** + * Change the Apply button text based upon the override select state. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to change the Apply button according to the current + * state. + */ + Drupal.behaviors.viewsUiOverrideSelect = { + attach: function (context) { + $(context).find('[data-drupal-selector="edit-override-dropdown"]').once('views-ui-override-button-text').each(function () { + // Closures! :( + var $context = $(context); + var $submit = $context.find('[id^=edit-submit]'); + var old_value = $submit.val(); + + $submit.once('views-ui-override-button-text') + .on('mouseup', function () { + $(this).val(old_value); + return true; + }); + + $(this).on('change', function () { + var $this = $(this); + if ($this.val() === 'default') { + $submit.val(Drupal.t('Apply (all displays)')); + } + else if ($this.val() === 'default_revert') { + $submit.val(Drupal.t('Revert to default')); + } + else { + $submit.val(Drupal.t('Apply (this display)')); + } + var $dialog = $context.closest('.ui-dialog-content'); + $dialog.trigger('dialogButtonsChange'); + }) + .trigger('change'); + }); + + } + }; + + /** + * Functionality for the remove link in the views UI. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for the remove view and remove display links. + */ + Drupal.behaviors.viewsUiHandlerRemoveLink = { + attach: function (context) { + var $context = $(context); + // Handle handler deletion by looking for the hidden checkbox and hiding + // the row. + $context.find('a.views-remove-link').once('views').on('click', function (event) { + var id = $(this).attr('id').replace('views-remove-link-', ''); + $context.find('#views-row-' + id).hide(); + $context.find('#views-removed-' + id).prop('checked', true); + event.preventDefault(); + }); + + // Handle display deletion by looking for the hidden checkbox and hiding + // the row. + $context.find('a.display-remove-link').once('display').on('click', function (event) { + var id = $(this).attr('id').replace('display-remove-link-', ''); + $context.find('#display-row-' + id).hide(); + $context.find('#display-removed-' + id).prop('checked', true); + event.preventDefault(); + }); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/views_ui/js/views-admin.js b/core/modules/views_ui/js/views-admin.js index 0fda80874895..da3e6c1e9b45 100644 --- a/core/modules/views_ui/js/views-admin.js +++ b/core/modules/views_ui/js/views-admin.js @@ -1,193 +1,97 @@ /** - * @file - * Some basic behaviors and utility functions for Views UI. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/views_ui/js/views-admin.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal, drupalSettings) { 'use strict'; - /** - * @namespace - */ Drupal.viewsUi = {}; - /** - * Improve the user experience of the views edit interface. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches toggling of SQL rewrite warning on the corresponding checkbox. - */ Drupal.behaviors.viewsUiEditView = { - attach: function () { - // Only show the SQL rewrite warning when the user has chosen the - // corresponding checkbox. + attach: function attach() { $('[data-drupal-selector="edit-query-options-disable-sql-rewrite"]').on('click', function () { $('.sql-rewrite-warning').toggleClass('js-hide'); }); } }; - /** - * In the add view wizard, use the view name to prepopulate form fields such - * as page title and menu link. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior for prepopulating page title and menu links, based on - * view name. - */ Drupal.behaviors.viewsUiAddView = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); - // Set up regular expressions to allow only numbers, letters, and dashes. + var exclude = new RegExp('[^a-z0-9\\-]+', 'g'); var replace = '-'; var suffix; - // The page title, block title, and menu link fields can all be - // prepopulated with the view name - no regular expression needed. var $fields = $context.find('[id^="edit-page-title"], [id^="edit-block-title"], [id^="edit-page-link-properties-title"]'); if ($fields.length) { if (!this.fieldsFiller) { this.fieldsFiller = new Drupal.viewsUi.FormFieldFiller($fields); - } - else { - // After an AJAX response, this.fieldsFiller will still have event - // handlers bound to the old version of the form fields (which don't - // exist anymore). The event handlers need to be unbound and then - // rebound to the new markup. Note that jQuery.live is difficult to - // make work in this case because the IDs of the form fields change - // on every AJAX response. + } else { this.fieldsFiller.rebind($fields); } } - // Prepopulate the path field with a URLified version of the view name. var $pathField = $context.find('[id^="edit-page-path"]'); if ($pathField.length) { if (!this.pathFiller) { this.pathFiller = new Drupal.viewsUi.FormFieldFiller($pathField, exclude, replace); - } - else { + } else { this.pathFiller.rebind($pathField); } } - // Populate the RSS feed field with a URLified version of the view name, - // and an .xml suffix (to make it unique). var $feedField = $context.find('[id^="edit-page-feed-properties-path"]'); if ($feedField.length) { if (!this.feedFiller) { suffix = '.xml'; this.feedFiller = new Drupal.viewsUi.FormFieldFiller($feedField, exclude, replace, suffix); - } - else { + } else { this.feedFiller.rebind($feedField); } } } }; - /** - * Constructor for the {@link Drupal.viewsUi.FormFieldFiller} object. - * - * Prepopulates a form field based on the view name. - * - * @constructor - * - * @param {jQuery} $target - * A jQuery object representing the form field or fields to prepopulate. - * @param {bool} [exclude=false] - * A regular expression representing characters to exclude from - * the target field. - * @param {string} [replace=''] - * A string to use as the replacement value for disallowed characters. - * @param {string} [suffix=''] - * A suffix to append at the end of the target field content. - */ Drupal.viewsUi.FormFieldFiller = function ($target, exclude, replace, suffix) { - - /** - * - * @type {jQuery} - */ this.source = $('#edit-label'); - /** - * - * @type {jQuery} - */ this.target = $target; - /** - * - * @type {bool} - */ this.exclude = exclude || false; - /** - * - * @type {string} - */ this.replace = replace || ''; - /** - * - * @type {string} - */ this.suffix = suffix || ''; - // Create bound versions of this instance's object methods to use as event - // handlers. This will let us easily unbind those specific handlers later - // on. NOTE: jQuery.proxy will not work for this because it assumes we want - // only one bound version of an object method, whereas we need one version - // per object instance. var self = this; - /** - * Populate the target form field with the altered source field value. - * - * @return {*} - * The result of the _populate call, which should be undefined. - */ - this.populate = function () { return self._populate.call(self); }; - - /** - * Stop prepopulating the form fields. - * - * @return {*} - * The result of the _unbind call, which should be undefined. - */ - this.unbind = function () { return self._unbind.call(self); }; + this.populate = function () { + return self._populate.call(self); + }; + + this.unbind = function () { + return self._unbind.call(self); + }; this.bind(); - // Object constructor; no return value. }; - $.extend(Drupal.viewsUi.FormFieldFiller.prototype, /** @lends Drupal.viewsUi.FormFieldFiller# */{ - - /** - * Bind the form-filling behavior. - */ - bind: function () { + $.extend(Drupal.viewsUi.FormFieldFiller.prototype, { + bind: function bind() { this.unbind(); - // Populate the form field when the source changes. + this.source.on('keyup.viewsUi change.viewsUi', this.populate); - // Quit populating the field as soon as it gets focus. + this.target.on('focus.viewsUi', this.unbind); }, - /** - * Get the source form field value as altered by the passed-in parameters. - * - * @return {string} - * The source form field value. - */ - getTransliterated: function () { + getTransliterated: function getTransliterated() { var from = this.source.val(); if (this.exclude) { from = from.toLowerCase().replace(this.exclude, this.replace); @@ -195,112 +99,60 @@ return from; }, - /** - * Populate the target form field with the altered source field value. - */ - _populate: function () { + _populate: function _populate() { var transliterated = this.getTransliterated(); var suffix = this.suffix; this.target.each(function (i) { - // Ensure that the maxlength is not exceeded by prepopulating the field. var maxlength = $(this).attr('maxlength') - suffix.length; $(this).val(transliterated.substr(0, maxlength) + suffix); }); }, - /** - * Stop prepopulating the form fields. - */ - _unbind: function () { + _unbind: function _unbind() { this.source.off('keyup.viewsUi change.viewsUi', this.populate); this.target.off('focus.viewsUi', this.unbind); }, - /** - * Bind event handlers to new form fields, after they're replaced via Ajax. - * - * @param {jQuery} $fields - * Fields to rebind functionality to. - */ - rebind: function ($fields) { + rebind: function rebind($fields) { this.target = $fields; this.bind(); } }); - /** - * Adds functionality for the add item form. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the functionality in {@link Drupal.viewsUi.AddItemForm} to the - * forms in question. - */ Drupal.behaviors.addItemForm = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); var $form = $context; - // The add handler form may have an id of views-ui-add-handler-form--n. + if (!$context.is('form[id^="views-ui-add-handler-form"]')) { $form = $context.find('form[id^="views-ui-add-handler-form"]'); } if ($form.once('views-ui-add-handler-form').length) { - // If we we have an unprocessed views-ui-add-handler-form, let's - // instantiate. new Drupal.viewsUi.AddItemForm($form); } } }; - /** - * Constructs a new AddItemForm. - * - * @constructor - * - * @param {jQuery} $form - * The form element used. - */ Drupal.viewsUi.AddItemForm = function ($form) { - - /** - * - * @type {jQuery} - */ this.$form = $form; this.$form.find('.views-filterable-options :checkbox').on('click', $.proxy(this.handleCheck, this)); - /** - * Find the wrapper of the displayed text. - */ this.$selected_div = this.$form.find('.views-selected-options').parent(); this.$selected_div.hide(); - /** - * - * @type {Array} - */ this.checkedItems = []; }; - /** - * Handles a checkbox check. - * - * @param {jQuery.Event} event - * The event triggered. - */ Drupal.viewsUi.AddItemForm.prototype.handleCheck = function (event) { var $target = $(event.target); var label = $.trim($target.closest('td').next().html()); - // Add/remove the checked item to the list. + if ($target.is(':checked')) { this.$selected_div.show().css('display', 'block'); this.checkedItems.push(label); - } - else { + } else { var position = $.inArray(label, this.checkedItems); - // Delete the item from the list and make sure that the list doesn't have - // undefined items left. + for (var i = 0; i < this.checkedItems.length; i++) { if (i === position) { this.checkedItems.splice(i, 1); @@ -308,7 +160,7 @@ break; } } - // Hide it again if none item is selected. + if (this.checkedItems.length === 0) { this.$selected_div.hide(); } @@ -316,30 +168,12 @@ this.refreshCheckedItems(); }; - /** - * Refresh the display of the checked items. - */ Drupal.viewsUi.AddItemForm.prototype.refreshCheckedItems = function () { - // Perhaps we should precache the text div, too. - this.$selected_div.find('.views-selected-options') - .html(this.checkedItems.join(', ')) - .trigger('dialogContentResize'); + this.$selected_div.find('.views-selected-options').html(this.checkedItems.join(', ')).trigger('dialogContentResize'); }; - /** - * The input field items that add displays must be rendered as `<input>` - * elements. The following behavior detaches the `<input>` elements from the - * DOM, wraps them in an unordered list, then appends them to the list of - * tabs. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Fixes the input elements needed. - */ Drupal.behaviors.viewsUiRenderAddViewButton = { - attach: function (context) { - // Build the add display menu and pull the display input buttons into it. + attach: function attach(context) { var $menu = $(context).find('#views-display-menu-tabs').once('views-ui-render-add-view-button'); if (!$menu.length) { return; @@ -347,11 +181,8 @@ var $addDisplayDropdown = $('<li class="add"><a href="#"><span class="icon add"></span>' + Drupal.t('Add') + '</a><ul class="action-list" style="display:none;"></ul></li>'); var $displayButtons = $menu.nextAll('input.add-display').detach(); - $displayButtons.appendTo($addDisplayDropdown.find('.action-list')).wrap('<li>') - .parent().eq(0).addClass('first').end().eq(-1).addClass('last'); - // Remove the 'Add ' prefix from the button labels since they're being - // placed in an 'Add' dropdown. @todo This assumes English, but so does - // $addDisplayDropdown above. Add support for translation. + $displayButtons.appendTo($addDisplayDropdown.find('.action-list')).wrap('<li>').parent().eq(0).addClass('first').end().eq(-1).addClass('last'); + $displayButtons.each(function () { var label = $(this).val(); if (label.substr(0, 4) === 'Add ') { @@ -360,19 +191,12 @@ }); $addDisplayDropdown.appendTo($menu); - // Add the click handler for the add display button. $menu.find('li.add > a').on('click', function (event) { event.preventDefault(); var $trigger = $(this); Drupal.behaviors.viewsUiRenderAddViewButton.toggleMenu($trigger); }); - // Add a mouseleave handler to close the dropdown when the user mouses - // away from the item. We use mouseleave instead of mouseout because - // the user is going to trigger mouseout when she moves from the trigger - // link to the sub menu items. - // We use the live binder because the open class on this item will be - // toggled on and off and we want the handler to take effect in the cases - // that the class is present, but not when it isn't. + $('li.add', $menu).on('mouseleave', function (event) { var $this = $(this); var $trigger = $this.children('a[href="#"]'); @@ -383,67 +207,29 @@ } }; - /** - * Toggle menu visibility. - * - * @param {jQuery} $trigger - * The element where the toggle was triggered. - * - * - * @note [@jessebeach] I feel like the following should be a more generic - * function and not written specifically for this UI, but I'm not sure - * where to put it. - */ Drupal.behaviors.viewsUiRenderAddViewButton.toggleMenu = function ($trigger) { $trigger.parent().toggleClass('open'); $trigger.next().slideToggle('fast'); }; - /** - * Add search options to the views ui. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches {@link Drupal.viewsUi.OptionsSearch} to the views ui filter - * options. - */ Drupal.behaviors.viewsUiSearchOptions = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); var $form = $context; - // The add handler form may have an id of views-ui-add-handler-form--n. + if (!$context.is('form[id^="views-ui-add-handler-form"]')) { $form = $context.find('form[id^="views-ui-add-handler-form"]'); } - // Make sure we don't add more than one event handler to the same form. + if ($form.once('views-ui-filter-options').length) { new Drupal.viewsUi.OptionsSearch($form); } } }; - /** - * Constructor for the viewsUi.OptionsSearch object. - * - * The OptionsSearch object filters the available options on a form according - * to the user's search term. Typing in "taxonomy" will show only those - * options containing "taxonomy" in their label. - * - * @constructor - * - * @param {jQuery} $form - * The form element. - */ Drupal.viewsUi.OptionsSearch = function ($form) { - - /** - * - * @type {jQuery} - */ this.$form = $form; - // Click on the title checks the box. this.$form.on('click', 'td.title', function (event) { var $target = $(event.currentTarget); $target.closest('tr').find('input').trigger('click'); @@ -456,13 +242,8 @@ this.$searchBox = this.$form.find(searchBoxSelector); this.$controlGroup = this.$form.find(controlGroupSelector); - /** - * Get a list of option labels and their corresponding divs and maintain it - * in memory, so we have as little overhead as possible at keyup time. - */ this.options = this.getOptions(this.$form.find('.filterable-option')); - // Trap the ENTER key in the search box so that it doesn't submit the form. this.$searchBox.on('keypress', function (event) { if (event.which === 13) { event.preventDefault(); @@ -470,19 +251,8 @@ }); }; - $.extend(Drupal.viewsUi.OptionsSearch.prototype, /** @lends Drupal.viewsUi.OptionsSearch# */{ - - /** - * Assemble a list of all the filterable options on the form. - * - * @param {jQuery} $allOptions - * A jQuery object representing the rows of filterable options to be - * shown and hidden depending on the user's search terms. - * - * @return {Array} - * An array of all the filterable options. - */ - getOptions: function ($allOptions) { + $.extend(Drupal.viewsUi.OptionsSearch.prototype, { + getOptions: function getOptions($allOptions) { var $title; var $description; var $option; @@ -493,41 +263,27 @@ $title = $option.find('.title'); $description = $option.find('.description'); options[i] = { - // Search on the lowercase version of the title text + description. searchText: $title.text().toLowerCase() + ' ' + $description.text().toLowerCase(), - // Maintain a reference to the jQuery object for each row, so we don't - // have to create a new object inside the performance-sensitive keyup - // handler. + $div: $option }; } return options; }, - /** - * Filter handler for the search box and type select that hides or shows the relevant - * options. - * - * @param {jQuery.Event} event - * The formUpdated event. - */ - handleFilter: function (event) { - // Determine the user's search query. The search text has been converted - // to lowercase. + handleFilter: function handleFilter(event) { var search = this.$searchBox.val().toLowerCase(); var words = search.split(' '); - // Get selected Group + var group = this.$controlGroup.val(); - // Search through the search texts in the form for matching text. this.options.forEach(function (option) { function hasWord(word) { return option.searchText.indexOf(word) !== -1; } var found = true; - // Each word in the search string has to match the item in order for the - // item to be shown. + if (search) { found = words.every(hasWord); } @@ -538,58 +294,32 @@ option.$div.toggle(found); }); - // Adapt dialog to content size. $(event.target).trigger('dialogContentResize'); } }); - /** - * Preview functionality in the views edit form. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the preview functionality to the view edit form. - */ Drupal.behaviors.viewsUiPreview = { - attach: function (context) { - // Only act on the edit view form. + attach: function attach(context) { var $contextualFiltersBucket = $(context).find('.views-display-column .views-ui-display-tab-bucket.argument'); if ($contextualFiltersBucket.length === 0) { return; } - // If the display has no contextual filters, hide the form where you - // enter the contextual filters for the live preview. If it has contextual - // filters, show the form. var $contextualFilters = $contextualFiltersBucket.find('.views-display-setting a'); if ($contextualFilters.length) { $('#preview-args').parent().show(); - } - else { + } else { $('#preview-args').parent().hide(); } - // Executes an initial preview. if ($('#edit-displays-live-preview').once('edit-displays-live-preview').is(':checked')) { $('#preview-submit').once('edit-displays-live-preview').trigger('click'); } } }; - /** - * Rearranges the filters. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attach handlers to make it possible to rearange the filters in the form - * in question. - * @see Drupal.viewsUi.RearrangeFilterHandler - */ Drupal.behaviors.viewsUiRearrangeFilter = { - attach: function (context) { - // Only act on the rearrange filter form. + attach: function attach(context) { if (typeof Drupal.tableDrag === 'undefined' || typeof Drupal.tableDrag['views-rearrange-filters'] === 'undefined') { return; } @@ -602,162 +332,58 @@ } }; - /** - * Improve the UI of the rearrange filters dialog box. - * - * @constructor - * - * @param {jQuery} $table - * The table in the filter form. - * @param {jQuery} $operator - * The filter groups operator element. - */ Drupal.viewsUi.RearrangeFilterHandler = function ($table, $operator) { - - /** - * Keep a reference to the `<table>` being altered and to the div containing - * the filter groups operator dropdown (if it exists). - */ this.table = $table; - /** - * - * @type {jQuery} - */ this.operator = $operator; - /** - * - * @type {bool} - */ this.hasGroupOperator = this.operator.length > 0; - /** - * Keep a reference to all draggable rows within the table. - * - * @type {jQuery} - */ this.draggableRows = $table.find('.draggable'); - /** - * Keep a reference to the buttons for adding and removing filter groups. - * - * @type {jQuery} - */ this.addGroupButton = $('input#views-add-group'); - /** - * @type {jQuery} - */ this.removeGroupButtons = $table.find('input.views-remove-group'); - // Add links that duplicate the functionality of the (hidden) add and remove - // buttons. this.insertAddRemoveFilterGroupLinks(); - // When there is a filter groups operator dropdown on the page, create - // duplicates of the dropdown between each pair of filter groups. if (this.hasGroupOperator) { - - /** - * @type {jQuery} - */ this.dropdowns = this.duplicateGroupsOperator(); this.syncGroupsOperators(); } - // Add methods to the tableDrag instance to account for operator cells - // (which span multiple rows), the operator labels next to each filter - // (e.g., "And" or "Or"), the filter groups, and other special aspects of - // this tableDrag instance. this.modifyTableDrag(); - // Initialize the operator labels (e.g., "And" or "Or") that are displayed - // next to the filters in each group, and bind a handler so that they change - // based on the values of the operator dropdown within that group. this.redrawOperatorLabels(); - $table.find('.views-group-title select') - .once('views-rearrange-filter-handler') - .on('change.views-rearrange-filter-handler', $.proxy(this, 'redrawOperatorLabels')); - - // Bind handlers so that when a "Remove" link is clicked, we: - // - Update the rowspans of cells containing an operator dropdown (since - // they need to change to reflect the number of rows in each group). - // - Redraw the operator labels next to the filters in the group (since the - // filter that is currently displayed last in each group is not supposed - // to have a label display next to it). - $table.find('a.views-groups-remove-link') - .once('views-rearrange-filter-handler') - .on('click.views-rearrange-filter-handler', $.proxy(this, 'updateRowspans')) - .on('click.views-rearrange-filter-handler', $.proxy(this, 'redrawOperatorLabels')); + $table.find('.views-group-title select').once('views-rearrange-filter-handler').on('change.views-rearrange-filter-handler', $.proxy(this, 'redrawOperatorLabels')); + + $table.find('a.views-groups-remove-link').once('views-rearrange-filter-handler').on('click.views-rearrange-filter-handler', $.proxy(this, 'updateRowspans')).on('click.views-rearrange-filter-handler', $.proxy(this, 'redrawOperatorLabels')); }; - $.extend(Drupal.viewsUi.RearrangeFilterHandler.prototype, /** @lends Drupal.viewsUi.RearrangeFilterHandler# */{ - - /** - * Insert links that allow filter groups to be added and removed. - */ - insertAddRemoveFilterGroupLinks: function () { - - // Insert a link for adding a new group at the top of the page, and make - // it match the action link styling used in a typical page.html.twig. - // Since Drupal does not provide a theme function for this markup this is - // the best we can do. - $('<ul class="action-links"><li><a id="views-add-group-link" href="#">' + this.addGroupButton.val() + '</a></li></ul>') - .prependTo(this.table.parent()) - // When the link is clicked, dynamically click the hidden form button - // for adding a new filter group. - .once('views-rearrange-filter-handler') - .find('#views-add-group-link') - .on('click.views-rearrange-filter-handler', $.proxy(this, 'clickAddGroupButton')); - - // Find each (visually hidden) button for removing a filter group and - // insert a link next to it. + $.extend(Drupal.viewsUi.RearrangeFilterHandler.prototype, { + insertAddRemoveFilterGroupLinks: function insertAddRemoveFilterGroupLinks() { + $('<ul class="action-links"><li><a id="views-add-group-link" href="#">' + this.addGroupButton.val() + '</a></li></ul>').prependTo(this.table.parent()).once('views-rearrange-filter-handler').find('#views-add-group-link').on('click.views-rearrange-filter-handler', $.proxy(this, 'clickAddGroupButton')); + var length = this.removeGroupButtons.length; var i; for (i = 0; i < length; i++) { var $removeGroupButton = $(this.removeGroupButtons[i]); var buttonId = $removeGroupButton.attr('id'); - $('<a href="#" class="views-remove-group-link">' + Drupal.t('Remove group') + '</a>') - .insertBefore($removeGroupButton) - // When the link is clicked, dynamically click the corresponding form - // button. - .once('views-rearrange-filter-handler') - .on('click.views-rearrange-filter-handler', {buttonId: buttonId}, $.proxy(this, 'clickRemoveGroupButton')); + $('<a href="#" class="views-remove-group-link">' + Drupal.t('Remove group') + '</a>').insertBefore($removeGroupButton).once('views-rearrange-filter-handler').on('click.views-rearrange-filter-handler', { buttonId: buttonId }, $.proxy(this, 'clickRemoveGroupButton')); } }, - /** - * Dynamically click the button that adds a new filter group. - * - * @param {jQuery.Event} event - * The event triggered. - */ - clickAddGroupButton: function (event) { + clickAddGroupButton: function clickAddGroupButton(event) { this.addGroupButton.trigger('mousedown'); event.preventDefault(); }, - /** - * Dynamically click a button for removing a filter group. - * - * @param {jQuery.Event} event - * Event being triggered, with event.data.buttonId set to the ID of the - * form button that should be clicked. - */ - clickRemoveGroupButton: function (event) { + clickRemoveGroupButton: function clickRemoveGroupButton(event) { this.table.find('#' + event.data.buttonId).trigger('mousedown'); event.preventDefault(); }, - /** - * Move the groups operator so that it's between the first two groups, and - * duplicate it between any subsequent groups. - * - * @return {jQuery} - * An operator element. - */ - duplicateGroupsOperator: function () { + duplicateGroupsOperator: function duplicateGroupsOperator() { var dropdowns; var newRow; var titleRow; @@ -768,26 +394,20 @@ return this.operator; } - // Get rid of the explanatory text around the operator; its placement is - // explanatory enough. this.operator.find('label').add('div.description').addClass('visually-hidden'); this.operator.find('select').addClass('form-select'); - // Keep a list of the operator dropdowns, so we can sync their behavior - // later. dropdowns = this.operator; - // Move the operator to a new row just above the second group. titleRow = $('tr#views-group-title-2'); newRow = $('<tr class="filter-group-operator-row"><td colspan="5"></td></tr>'); newRow.find('td').append(this.operator); newRow.insertBefore(titleRow); var length = titleRows.length; - // Starting with the third group, copy the operator to a new row above the - // group title. + for (var i = 2; i < length; i++) { titleRow = $(titleRows[i]); - // Make a copy of the operator dropdown and put it in a new table row. + var fakeOperator = this.operator.clone(); fakeOperator.attr('id', ''); newRow = $('<tr class="filter-group-operator-row"><td colspan="5"></td></tr>'); @@ -799,63 +419,30 @@ return dropdowns; }, - /** - * Make the duplicated groups operators change in sync with each other. - */ - syncGroupsOperators: function () { + syncGroupsOperators: function syncGroupsOperators() { if (this.dropdowns.length < 2) { - // We only have one dropdown (or none at all), so there's nothing to - // sync. return; } this.dropdowns.on('change', $.proxy(this, 'operatorChangeHandler')); }, - /** - * Click handler for the operators that appear between filter groups. - * - * Forces all operator dropdowns to have the same value. - * - * @param {jQuery.Event} event - * The event triggered. - */ - operatorChangeHandler: function (event) { + operatorChangeHandler: function operatorChangeHandler(event) { var $target = $(event.target); var operators = this.dropdowns.find('select').not($target); - // Change the other operators to match this new value. operators.val($target.val()); }, - /** - * @method - */ - modifyTableDrag: function () { + modifyTableDrag: function modifyTableDrag() { var tableDrag = Drupal.tableDrag['views-rearrange-filters']; var filterHandler = this; - /** - * Override the row.onSwap method from tabledrag.js. - * - * When a row is dragged to another place in the table, several things - * need to occur. - * - The row needs to be moved so that it's within one of the filter - * groups. - * - The operator cells that span multiple rows need their rowspan - * attributes updated to reflect the number of rows in each group. - * - The operator labels that are displayed next to each filter need to - * be redrawn, to account for the row's new location. - */ tableDrag.row.prototype.onSwap = function () { if (filterHandler.hasGroupOperator) { - // Make sure the row that just got moved (this.group) is inside one - // of the filter groups (i.e. below an empty marker row or a - // draggable). If it isn't, move it down one. var thisRow = $(this.group); var previousRow = thisRow.prev('tr'); if (previousRow.length && !previousRow.hasClass('group-message') && !previousRow.hasClass('draggable')) { - // Move the dragged row down one. var next = thisRow.next(); if (next.is('tr')) { this.swap('after', next); @@ -863,31 +450,19 @@ } filterHandler.updateRowspans(); } - // Redraw the operator labels that are displayed next to each filter, to - // account for the row's new location. + filterHandler.redrawOperatorLabels(); }; - /** - * Override the onDrop method from tabledrag.js. - */ tableDrag.onDrop = function () { - // If the tabledrag change marker (i.e., the "*") has been inserted - // inside a row after the operator label (i.e., "And" or "Or") - // rearrange the items so the operator label continues to appear last. var changeMarker = $(this.oldRowElement).find('.tabledrag-changed'); if (changeMarker.length) { - // Search for occurrences of the operator label before the change - // marker, and reverse them. var operatorLabel = changeMarker.prevAll('.views-operator-label'); if (operatorLabel.length) { operatorLabel.insertAfter(changeMarker); } } - // Make sure the "group" dropdown is properly updated when rows are - // dragged into an empty filter group. This is borrowed heavily from - // the block.js implementation of tableDrag.onDrop(). var groupRow = $(this.rowObject.element).prevAll('tr.group-message').get(0); var groupName = groupRow.className.replace(/([^ ]+[ ]+)*group-([^ ]+)-message([ ]+[^ ]+)*/, '$2'); var groupField = $('select.views-group-select', this.rowObject.element); @@ -899,57 +474,30 @@ }; }, - /** - * Redraw the operator labels that are displayed next to each filter. - */ - redrawOperatorLabels: function () { + redrawOperatorLabels: function redrawOperatorLabels() { for (var i = 0; i < this.draggableRows.length; i++) { - // Within the row, the operator labels are displayed inside the first - // table cell (next to the filter name). var $draggableRow = $(this.draggableRows[i]); var $firstCell = $draggableRow.find('td').eq(0); if ($firstCell.length) { - // The value of the operator label ("And" or "Or") is taken from the - // first operator dropdown we encounter, going backwards from the - // current row. This dropdown is the one associated with the current - // row's filter group. var operatorValue = $draggableRow.prevAll('.views-group-title').find('option:selected').html(); var operatorLabel = '<span class="views-operator-label">' + operatorValue + '</span>'; - // If the next visible row after this one is a draggable filter row, - // display the operator label next to the current row. (Checking for - // visibility is necessary here since the "Remove" links hide the - // removed row but don't actually remove it from the document). + var $nextRow = $draggableRow.nextAll(':visible').eq(0); var $existingOperatorLabel = $firstCell.find('.views-operator-label'); if ($nextRow.hasClass('draggable')) { - // If an operator label was already there, replace it with the new - // one. if ($existingOperatorLabel.length) { $existingOperatorLabel.replaceWith(operatorLabel); + } else { + $firstCell.append(operatorLabel); + } + } else { + $existingOperatorLabel.remove(); } - // Otherwise, append the operator label to the end of the table - // cell. - else { - $firstCell.append(operatorLabel); - } - } - // If the next row doesn't contain a filter, then this is the last row - // in the group. We don't want to display the operator there (since - // operators should only display between two related filters, e.g. - // "filter1 AND filter2 AND filter3"). So we remove any existing label - // that this row has. - else { - $existingOperatorLabel.remove(); - } } } }, - /** - * Update the rowspan attribute of each cell containing an operator - * dropdown. - */ - updateRowspans: function () { + updateRowspans: function updateRowspans() { var $row; var $currentEmptyRow; var draggableCount; @@ -959,39 +507,25 @@ for (var i = 0; i < length; i++) { $row = $(rows[i]); if ($row.hasClass('views-group-title')) { - // This row is a title row. - // Keep a reference to the cell containing the dropdown operator. $operatorCell = $row.find('td.group-operator'); - // Assume this filter group is empty, until we find otherwise. + draggableCount = 0; $currentEmptyRow = $row.next('tr'); $currentEmptyRow.removeClass('group-populated').addClass('group-empty'); - // The cell with the dropdown operator should span the title row and - // the "this group is empty" row. + $operatorCell.attr('rowspan', 2); - } - else if ($row.hasClass('draggable') && $row.is(':visible')) { - // We've found a visible filter row, so we now know the group isn't - // empty. + } else if ($row.hasClass('draggable') && $row.is(':visible')) { draggableCount++; $currentEmptyRow.removeClass('group-empty').addClass('group-populated'); - // The operator cell should span all draggable rows, plus the title. + $operatorCell.attr('rowspan', draggableCount + 1); } } } }); - /** - * Add a select all checkbox, which checks each checkbox at once. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches select all functionality to the views filter form. - */ Drupal.behaviors.viewsFilterConfigSelectAll = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); var $selectAll = $context.find('.js-form-item-options-value-all').once('filterConfigSelectAll'); @@ -999,14 +533,11 @@ var $checkboxes = $selectAll.closest('.form-checkboxes').find('.js-form-type-checkbox:not(.js-form-item-options-value-all) input[type="checkbox"]'); if ($selectAll.length) { - // Show the select all checkbox. $selectAll.show(); $selectAllCheckbox.on('click', function () { - // Update all checkbox beside the select all checkbox. $checkboxes.prop('checked', $(this).is(':checked')); }); - // Uncheck the select all checkbox if any of the others are unchecked. $checkboxes.on('click', function () { if ($(this).is('checked') === false) { $selectAllCheckbox.prop('checked', false); @@ -1016,30 +547,14 @@ } }; - /** - * Remove icon class from elements that are themed as buttons or dropbuttons. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Removes the icon class from certain views elements. - */ Drupal.behaviors.viewsRemoveIconClass = { - attach: function (context) { + attach: function attach(context) { $(context).find('.dropbutton').once('dropbutton-icon').find('.icon').removeClass('icon'); } }; - /** - * Change "Expose filter" buttons into checkboxes. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Changes buttons into checkboxes via {@link Drupal.viewsUi.Checkboxifier}. - */ Drupal.behaviors.viewsUiCheckboxify = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $buttons = $('[data-drupal-selector="edit-options-expose-button-button"], [data-drupal-selector="edit-options-group-button-button"]').once('views-ui-checkboxify'); var length = $buttons.length; var i; @@ -1049,17 +564,8 @@ } }; - /** - * Change the default widget to select the default group according to the - * selected widget for the exposed group. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Changes the default widget based on user input. - */ Drupal.behaviors.viewsUiChangeDefaultWidget = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); function changeDefaultWidget(event) { @@ -1067,110 +573,64 @@ $context.find('input.default-radios').parent().hide(); $context.find('td.any-default-radios-row').parent().hide(); $context.find('input.default-checkboxes').parent().show(); - } - else { + } else { $context.find('input.default-checkboxes').parent().hide(); $context.find('td.any-default-radios-row').parent().show(); $context.find('input.default-radios').parent().show(); } } - // Update on widget change. - $context.find('input[name="options[group_info][multiple]"]') - .on('change', changeDefaultWidget) - // Update the first time the form is rendered. - .trigger('change'); + $context.find('input[name="options[group_info][multiple]"]').on('change', changeDefaultWidget).trigger('change'); } }; - /** - * Attaches expose filter button to a checkbox that triggers its click event. - * - * @constructor - * - * @param {HTMLElement} button - * The DOM object representing the button to be checkboxified. - */ Drupal.viewsUi.Checkboxifier = function (button) { this.$button = $(button); this.$parent = this.$button.parent('div.views-expose, div.views-grouped'); this.$input = this.$parent.find('input:checkbox, input:radio'); - // Hide the button and its description. + this.$button.hide(); this.$parent.find('.exposed-description, .grouped-description').hide(); this.$input.on('click', $.proxy(this, 'clickHandler')); - }; - /** - * When the checkbox is checked or unchecked, simulate a button press. - * - * @param {jQuery.Event} e - * The event triggered. - */ Drupal.viewsUi.Checkboxifier.prototype.clickHandler = function (e) { - this.$button - .trigger('click') - .trigger('submit'); + this.$button.trigger('click').trigger('submit'); }; - /** - * Change the Apply button text based upon the override select state. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior to change the Apply button according to the current - * state. - */ Drupal.behaviors.viewsUiOverrideSelect = { - attach: function (context) { + attach: function attach(context) { $(context).find('[data-drupal-selector="edit-override-dropdown"]').once('views-ui-override-button-text').each(function () { - // Closures! :( var $context = $(context); var $submit = $context.find('[id^=edit-submit]'); var old_value = $submit.val(); - $submit.once('views-ui-override-button-text') - .on('mouseup', function () { - $(this).val(old_value); - return true; - }); + $submit.once('views-ui-override-button-text').on('mouseup', function () { + $(this).val(old_value); + return true; + }); $(this).on('change', function () { var $this = $(this); if ($this.val() === 'default') { $submit.val(Drupal.t('Apply (all displays)')); - } - else if ($this.val() === 'default_revert') { + } else if ($this.val() === 'default_revert') { $submit.val(Drupal.t('Revert to default')); - } - else { + } else { $submit.val(Drupal.t('Apply (this display)')); } var $dialog = $context.closest('.ui-dialog-content'); $dialog.trigger('dialogButtonsChange'); - }) - .trigger('change'); + }).trigger('change'); }); - } }; - /** - * Functionality for the remove link in the views UI. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches behavior for the remove view and remove display links. - */ Drupal.behaviors.viewsUiHandlerRemoveLink = { - attach: function (context) { + attach: function attach(context) { var $context = $(context); - // Handle handler deletion by looking for the hidden checkbox and hiding - // the row. + $context.find('a.views-remove-link').once('views').on('click', function (event) { var id = $(this).attr('id').replace('views-remove-link-', ''); $context.find('#views-row-' + id).hide(); @@ -1178,8 +638,6 @@ event.preventDefault(); }); - // Handle display deletion by looking for the hidden checkbox and hiding - // the row. $context.find('a.display-remove-link').once('display').on('click', function (event) { var id = $(this).attr('id').replace('display-remove-link-', ''); $context.find('#display-row-' + id).hide(); @@ -1188,5 +646,4 @@ }); } }; - -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/views_ui/js/views_ui.listing.es6.js b/core/modules/views_ui/js/views_ui.listing.es6.js new file mode 100644 index 000000000000..7d19cd498d2e --- /dev/null +++ b/core/modules/views_ui/js/views_ui.listing.es6.js @@ -0,0 +1,54 @@ +/** + * @file + * Views listing behaviors. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Filters the view listing tables by a text input search string. + * + * Text search input: input.views-filter-text + * Target table: input.views-filter-text[data-table] + * Source text: [data-drupal-selector="views-table-filter-text-source"] + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the filter functionality to the views admin text search field. + */ + Drupal.behaviors.viewTableFilterByText = { + attach: function (context, settings) { + var $input = $('input.views-filter-text').once('views-filter-text'); + var $table = $($input.attr('data-table')); + var $rows; + + function filterViewList(e) { + var query = $(e.target).val().toLowerCase(); + + function showViewRow(index, row) { + var $row = $(row); + var $sources = $row.find('[data-drupal-selector="views-table-filter-text-source"]'); + var textMatch = $sources.text().toLowerCase().indexOf(query) !== -1; + $row.closest('tr').toggle(textMatch); + } + + // Filter if the length of the query is at least 2 characters. + if (query.length >= 2) { + $rows.each(showViewRow); + } + else { + $rows.show(); + } + } + + if ($table.length) { + $rows = $table.find('tbody tr'); + $input.on('keyup', filterViewList); + } + } + }; + +}(jQuery, Drupal)); diff --git a/core/modules/views_ui/js/views_ui.listing.js b/core/modules/views_ui/js/views_ui.listing.js index 7d19cd498d2e..7c998bf01254 100644 --- a/core/modules/views_ui/js/views_ui.listing.js +++ b/core/modules/views_ui/js/views_ui.listing.js @@ -1,26 +1,17 @@ /** - * @file - * Views listing behaviors. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./modules/views_ui/js/views_ui.listing.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Filters the view listing tables by a text input search string. - * - * Text search input: input.views-filter-text - * Target table: input.views-filter-text[data-table] - * Source text: [data-drupal-selector="views-table-filter-text-source"] - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the filter functionality to the views admin text search field. - */ Drupal.behaviors.viewTableFilterByText = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $input = $('input.views-filter-text').once('views-filter-text'); var $table = $($input.attr('data-table')); var $rows; @@ -35,11 +26,9 @@ $row.closest('tr').toggle(textMatch); } - // Filter if the length of the query is at least 2 characters. if (query.length >= 2) { $rows.each(showViewRow); - } - else { + } else { $rows.show(); } } @@ -50,5 +39,4 @@ } } }; - -}(jQuery, Drupal)); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/package.json b/core/package.json index 533386ace57c..bb9fee002f56 100644 --- a/core/package.json +++ b/core/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "babel-core": "6.24.1", + "babel-plugin-add-header-comment": "1.0.3", "babel-preset-env": "1.4.0", "chokidar": "1.6.1", "cross-env": "^4.0.0", diff --git a/core/scripts/js/changeOrAdded.js b/core/scripts/js/changeOrAdded.js index 1f8b1ee128f7..e6d1a96243ca 100644 --- a/core/scripts/js/changeOrAdded.js +++ b/core/scripts/js/changeOrAdded.js @@ -12,7 +12,14 @@ module.exports = (filePath) => { filePath, { sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false, - comments: false + comments: false, + plugins: [ + ['add-header-comment', { + 'header': [ + `DO NOT EDIT THIS FILE.\nAll changes should be applied to ${filePath}\nSee the following change record for more information,\nhttps://www.drupal.org/node/2873849\n@preserve` + ] + }] + ] }, (err, result) => { if (err) { diff --git a/core/scripts/js/rename-js-files-to-es6.sh b/core/scripts/js/rename-js-files-to-es6.sh deleted file mode 100644 index 5ae2a890122f..000000000000 --- a/core/scripts/js/rename-js-files-to-es6.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -# Rename *.js files in *.es6.js. Only need to be run once. -# Should be removed after *.es6.js files are committed to core. -# -# @internal This file is part of the core javascript build process and is only -# meant to be used in that context. - -for js in `find ./{misc,modules,themes} -name '*.js'`; -do - mv ${js} ${js%???}.es6.js; -done diff --git a/core/themes/bartik/color/preview.es6.js b/core/themes/bartik/color/preview.es6.js new file mode 100644 index 000000000000..da8e0ca4e0b9 --- /dev/null +++ b/core/themes/bartik/color/preview.es6.js @@ -0,0 +1,49 @@ +/** + * @file + * Preview for the Bartik theme. + */ +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + Drupal.color = { + logoChanged: false, + callback: function (context, settings, $form) { + // Change the logo to be the real one. + if (!this.logoChanged) { + $('.color-preview .color-preview-logo img').attr('src', drupalSettings.color.logo); + this.logoChanged = true; + } + // Remove the logo if the setting is toggled off. + if (drupalSettings.color.logo === null) { + $('div').remove('.color-preview-logo'); + } + + var $colorPreview = $form.find('.color-preview'); + var $colorPalette = $form.find('.js-color-palette'); + + // Solid background. + $colorPreview.css('backgroundColor', $colorPalette.find('input[name="palette[bg]"]').val()); + + // Text preview. + $colorPreview.find('.color-preview-main h2, .color-preview .preview-content').css('color', $colorPalette.find('input[name="palette[text]"]').val()); + $colorPreview.find('.color-preview-content a').css('color', $colorPalette.find('input[name="palette[link]"]').val()); + + // Sidebar block. + var $colorPreviewBlock = $colorPreview.find('.color-preview-sidebar .color-preview-block'); + $colorPreviewBlock.css('background-color', $colorPalette.find('input[name="palette[sidebar]"]').val()); + $colorPreviewBlock.css('border-color', $colorPalette.find('input[name="palette[sidebarborders]"]').val()); + + // Footer wrapper background. + $colorPreview.find('.color-preview-footer-wrapper').css('background-color', $colorPalette.find('input[name="palette[footer]"]').val()); + + // CSS3 Gradients. + var gradient_start = $colorPalette.find('input[name="palette[top]"]').val(); + var gradient_end = $colorPalette.find('input[name="palette[bottom]"]').val(); + + $colorPreview.find('.color-preview-header').attr('style', 'background-color: ' + gradient_start + '; background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(' + gradient_start + '), to(' + gradient_end + ')); background-image: -moz-linear-gradient(-90deg, ' + gradient_start + ', ' + gradient_end + ');'); + + $colorPreview.find('.color-preview-site-name').css('color', $colorPalette.find('input[name="palette[titleslogan]"]').val()); + } + }; +})(jQuery, Drupal, drupalSettings); diff --git a/core/themes/bartik/color/preview.js b/core/themes/bartik/color/preview.js index da8e0ca4e0b9..ad34b2bc77e0 100644 --- a/core/themes/bartik/color/preview.js +++ b/core/themes/bartik/color/preview.js @@ -1,20 +1,23 @@ /** - * @file - * Preview for the Bartik theme. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./themes/bartik/color/preview.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + (function ($, Drupal, drupalSettings) { 'use strict'; Drupal.color = { logoChanged: false, - callback: function (context, settings, $form) { - // Change the logo to be the real one. + callback: function callback(context, settings, $form) { if (!this.logoChanged) { $('.color-preview .color-preview-logo img').attr('src', drupalSettings.color.logo); this.logoChanged = true; } - // Remove the logo if the setting is toggled off. + if (drupalSettings.color.logo === null) { $('div').remove('.color-preview-logo'); } @@ -22,22 +25,17 @@ var $colorPreview = $form.find('.color-preview'); var $colorPalette = $form.find('.js-color-palette'); - // Solid background. $colorPreview.css('backgroundColor', $colorPalette.find('input[name="palette[bg]"]').val()); - // Text preview. $colorPreview.find('.color-preview-main h2, .color-preview .preview-content').css('color', $colorPalette.find('input[name="palette[text]"]').val()); $colorPreview.find('.color-preview-content a').css('color', $colorPalette.find('input[name="palette[link]"]').val()); - // Sidebar block. var $colorPreviewBlock = $colorPreview.find('.color-preview-sidebar .color-preview-block'); $colorPreviewBlock.css('background-color', $colorPalette.find('input[name="palette[sidebar]"]').val()); $colorPreviewBlock.css('border-color', $colorPalette.find('input[name="palette[sidebarborders]"]').val()); - // Footer wrapper background. $colorPreview.find('.color-preview-footer-wrapper').css('background-color', $colorPalette.find('input[name="palette[footer]"]').val()); - // CSS3 Gradients. var gradient_start = $colorPalette.find('input[name="palette[top]"]').val(); var gradient_end = $colorPalette.find('input[name="palette[bottom]"]').val(); @@ -46,4 +44,4 @@ $colorPreview.find('.color-preview-site-name').css('color', $colorPalette.find('input[name="palette[titleslogan]"]').val()); } }; -})(jQuery, Drupal, drupalSettings); +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/themes/seven/js/mobile.install.es6.js b/core/themes/seven/js/mobile.install.es6.js new file mode 100644 index 000000000000..e7a0b5c18ac9 --- /dev/null +++ b/core/themes/seven/js/mobile.install.es6.js @@ -0,0 +1,33 @@ +(function () { + + 'use strict'; + + function findActiveStep(steps) { + for (var i = 0; i < steps.length; i++) { + if (steps[i].className === 'is-active') { + return i + 1; + } + } + // The final "Finished" step is never "active". + if (steps[steps.length - 1].className === 'done') { + return steps.length; + } + return 0; + } + + function installStepsSetup() { + var steps = document.querySelectorAll('.task-list li'); + if (steps.length) { + var header = document.querySelector('header[role="banner"]'); + var stepIndicator = document.createElement('div'); + stepIndicator.className = 'step-indicator'; + stepIndicator.innerHTML = findActiveStep(steps) + '/' + steps.length; + header.appendChild(stepIndicator); + } + } + + if (document.addEventListener) { + document.addEventListener('DOMContentLoaded', installStepsSetup); + } + +})(); diff --git a/core/themes/seven/js/mobile.install.js b/core/themes/seven/js/mobile.install.js index e7a0b5c18ac9..a10f1b8cb22c 100644 --- a/core/themes/seven/js/mobile.install.js +++ b/core/themes/seven/js/mobile.install.js @@ -1,3 +1,11 @@ +/** +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./themes/seven/js/mobile.install.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + (function () { 'use strict'; @@ -8,7 +16,7 @@ return i + 1; } } - // The final "Finished" step is never "active". + if (steps[steps.length - 1].className === 'done') { return steps.length; } @@ -29,5 +37,4 @@ if (document.addEventListener) { document.addEventListener('DOMContentLoaded', installStepsSetup); } - -})(); +})(); \ No newline at end of file diff --git a/core/themes/seven/js/nav-tabs.es6.js b/core/themes/seven/js/nav-tabs.es6.js new file mode 100644 index 000000000000..ba44ffb944c9 --- /dev/null +++ b/core/themes/seven/js/nav-tabs.es6.js @@ -0,0 +1,55 @@ +/** + * @file + * Responsive navigation tabs. + * + * This also supports collapsible navigable is the 'is-collapsible' class is + * added to the main element, and a target element is included. + */ +(function ($, Drupal) { + + 'use strict'; + + function init(i, tab) { + var $tab = $(tab); + var $target = $tab.find('[data-drupal-nav-tabs-target]'); + var isCollapsible = $tab.hasClass('is-collapsible'); + + function openMenu(e) { + $target.toggleClass('is-open'); + } + + function handleResize(e) { + $tab.addClass('is-horizontal'); + var $tabs = $tab.find('.tabs'); + var isHorizontal = $tabs.outerHeight() <= $tabs.find('.tabs__tab').outerHeight(); + $tab.toggleClass('is-horizontal', isHorizontal); + if (isCollapsible) { + $tab.toggleClass('is-collapse-enabled', !isHorizontal); + } + if (isHorizontal) { + $target.removeClass('is-open'); + } + } + + $tab.addClass('position-container is-horizontal-enabled'); + + $tab.on('click.tabs', '[data-drupal-nav-tabs-trigger]', openMenu); + $(window).on('resize.tabs', Drupal.debounce(handleResize, 150)).trigger('resize.tabs'); + } + + /** + * Initialise the tabs JS. + */ + Drupal.behaviors.navTabs = { + attach: function (context, settings) { + var $tabs = $(context).find('[data-drupal-nav-tabs]'); + if ($tabs.length) { + var notSmartPhone = window.matchMedia('(min-width: 300px)'); + if (notSmartPhone.matches) { + $tabs.once('nav-tabs').each(init); + } + } + } + }; + +})(jQuery, Drupal); diff --git a/core/themes/seven/js/nav-tabs.js b/core/themes/seven/js/nav-tabs.js index ba44ffb944c9..ec25c5201c17 100644 --- a/core/themes/seven/js/nav-tabs.js +++ b/core/themes/seven/js/nav-tabs.js @@ -1,10 +1,11 @@ /** - * @file - * Responsive navigation tabs. - * - * This also supports collapsible navigable is the 'is-collapsible' class is - * added to the main element, and a target element is included. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./themes/seven/js/nav-tabs.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ + (function ($, Drupal) { 'use strict'; @@ -37,11 +38,8 @@ $(window).on('resize.tabs', Drupal.debounce(handleResize, 150)).trigger('resize.tabs'); } - /** - * Initialise the tabs JS. - */ Drupal.behaviors.navTabs = { - attach: function (context, settings) { + attach: function attach(context, settings) { var $tabs = $(context).find('[data-drupal-nav-tabs]'); if ($tabs.length) { var notSmartPhone = window.matchMedia('(min-width: 300px)'); @@ -51,5 +49,4 @@ } } }; - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/themes/seven/js/responsive-details.es6.js b/core/themes/seven/js/responsive-details.es6.js new file mode 100644 index 000000000000..8fdb4530f3bd --- /dev/null +++ b/core/themes/seven/js/responsive-details.es6.js @@ -0,0 +1,57 @@ +/** + * @file + * Provides responsive behaviors to HTML details elements. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Initializes the responsive behaviors for details elements. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the responsive behavior to status report specific details elements. + */ + Drupal.behaviors.responsiveDetails = { + attach: function (context) { + var $details = $(context).find('details').once('responsive-details'); + + if (!$details.length) { + return; + } + + function detailsToggle(matches) { + if (matches) { + $details.attr('open', true); + $summaries.attr('aria-expanded', true); + $summaries.on('click.details-open', false); + } + else { + // If user explicitly opened one, leave it alone. + var $notPressed = $details + .find('> summary[aria-pressed!=true]') + .attr('aria-expanded', false); + $notPressed + .parent('details') + .attr('open', false); + // After resize, allow user to close previously opened details. + $summaries.off('.details-open'); + } + } + + function handleDetailsMQ(event) { + detailsToggle(event.matches); + } + + var $summaries = $details.find('> summary'); + var mql = window.matchMedia('(min-width:48em)'); + mql.addListener(handleDetailsMQ); + detailsToggle(mql.matches); + } + }; + + +})(jQuery, Drupal); diff --git a/core/themes/seven/js/responsive-details.js b/core/themes/seven/js/responsive-details.js index 8fdb4530f3bd..63d22b3a50ed 100644 --- a/core/themes/seven/js/responsive-details.js +++ b/core/themes/seven/js/responsive-details.js @@ -1,22 +1,17 @@ /** - * @file - * Provides responsive behaviors to HTML details elements. - */ +* DO NOT EDIT THIS FILE. +* All changes should be applied to ./themes/seven/js/responsive-details.es6.js +* See the following change record for more information, +* https://www.drupal.org/node/2873849 +* @preserve +**/ (function ($, Drupal) { 'use strict'; - /** - * Initializes the responsive behaviors for details elements. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the responsive behavior to status report specific details elements. - */ Drupal.behaviors.responsiveDetails = { - attach: function (context) { + attach: function attach(context) { var $details = $(context).find('details').once('responsive-details'); if (!$details.length) { @@ -28,16 +23,10 @@ $details.attr('open', true); $summaries.attr('aria-expanded', true); $summaries.on('click.details-open', false); - } - else { - // If user explicitly opened one, leave it alone. - var $notPressed = $details - .find('> summary[aria-pressed!=true]') - .attr('aria-expanded', false); - $notPressed - .parent('details') - .attr('open', false); - // After resize, allow user to close previously opened details. + } else { + var $notPressed = $details.find('> summary[aria-pressed!=true]').attr('aria-expanded', false); + $notPressed.parent('details').attr('open', false); + $summaries.off('.details-open'); } } @@ -52,6 +41,4 @@ detailsToggle(mql.matches); } }; - - -})(jQuery, Drupal); +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/yarn.lock b/core/yarn.lock index 272049f8f845..d4bab4acd4ae 100644 --- a/core/yarn.lock +++ b/core/yarn.lock @@ -322,6 +322,10 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-add-header-comment@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/babel-plugin-add-header-comment/-/babel-plugin-add-header-comment-1.0.3.tgz#511c4901062640d5a480b4ac3edd6944195850ec" + babel-plugin-check-es2015-constants@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz#35157b101426fd2ffd3da3f75c7d1e91835bbf8a" -- GitLab