From e853a9cc57c211e1d388e4b1658e71c614cb9442 Mon Sep 17 00:00:00 2001 From: Andrew Nacin Date: Wed, 5 Mar 2014 20:41:14 +0000 Subject: [PATCH] Add widget management to the customizer. This brings in the Widget Customizer plugin: https://wordpress.org/plugins/widget-customizer/. props westonruter, shaunandrews, michael-arestad, johnregan3, akeda, topher1kenobe, topquarky, bobbravo2, ricardocorreia. And for good measure, props westonruter. see #27112. Built from https://develop.svn.wordpress.org/trunk@27419 git-svn-id: http://core.svn.wordpress.org/trunk@27266 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-admin/admin-ajax.php | 2 +- wp-admin/css/customize-widgets-rtl.css | 623 ++++++ wp-admin/css/customize-widgets.css | 623 ++++++ wp-admin/includes/ajax-actions.php | 7 + wp-admin/js/customize-widgets.js | 1733 +++++++++++++++++ wp-admin/js/customize-widgets.min.js | 1 + wp-includes/class-wp-customize-control.php | 74 +- wp-includes/class-wp-customize-manager.php | 3 + wp-includes/class-wp-customize-widgets.php | 1264 ++++++++++++ wp-includes/js/customize-preview-widgets.js | 115 ++ .../js/customize-preview-widgets.min.js | 1 + 11 files changed, 4444 insertions(+), 2 deletions(-) create mode 100644 wp-admin/css/customize-widgets-rtl.css create mode 100644 wp-admin/css/customize-widgets.css create mode 100644 wp-admin/js/customize-widgets.js create mode 100644 wp-admin/js/customize-widgets.min.js create mode 100644 wp-includes/class-wp-customize-widgets.php create mode 100644 wp-includes/js/customize-preview-widgets.js create mode 100644 wp-includes/js/customize-preview-widgets.min.js diff --git a/wp-admin/admin-ajax.php b/wp-admin/admin-ajax.php index d57d56b1d4..838877efc8 100644 --- a/wp-admin/admin-ajax.php +++ b/wp-admin/admin-ajax.php @@ -58,7 +58,7 @@ $core_actions_post = array( 'wp-remove-post-lock', 'dismiss-wp-pointer', 'upload-attachment', 'get-attachment', 'query-attachments', 'save-attachment', 'save-attachment-compat', 'send-link-to-editor', 'send-attachment-to-editor', 'save-attachment-order', 'heartbeat', 'get-revision-diffs', - 'save-user-color-scheme', + 'save-user-color-scheme', 'update-widget', ); // Register core Ajax calls. diff --git a/wp-admin/css/customize-widgets-rtl.css b/wp-admin/css/customize-widgets-rtl.css new file mode 100644 index 0000000000..124a7aebdf --- /dev/null +++ b/wp-admin/css/customize-widgets-rtl.css @@ -0,0 +1,623 @@ +.wp-full-overlay-sidebar { + overflow: visible; +} + +/** + * Hide all sidebar sections by default, only show them (via JS) once the + * preview loads and we know whether the sidebars are used in the template. + */ + +.control-section[id^="accordion-section-sidebar-widgets-"], +.customize-control-sidebar_widgets label, +.customize-control-sidebar_widgets .hide-if-js { + /* The link in .customize-control-sidebar_widgets .hide-if-js will fail if it ever gets used. */ + display:none; +} + +.customize-control-widget_form .widget-top { + -webkit-transition: opacity 0.5s; + transition: opacity 0.5s; +} + +.customize-control-widget_form:not(.widget-rendered) .widget-top { + opacity: 0.5; +} + + +.customize-control-widget_form .widget-control-save { + display: none; +} + +.customize-control-widget_form .spinner { + display: inline; + opacity: 0.0; + -webkit-transition: opacity 0.1s; + transition: opacity 0.1s; +} +.customize-control-widget_form.previewer-loading .spinner { + opacity: 1.0; +} + +.customize-control-widget_form .widget { + margin-bottom: 0; +} + +.customize-control-widget_form:not(.wide-widget-control) { + /** + * Prevent plugins (e.g. Widget Visibility in Jetpack) from forcing widget forms + * to be wide and so overflow the customizer panel + */ + right: auto !important; + max-width: 100%; +} +.customize-control-widget_form.wide-widget-control .widget-inside { + position: fixed; + right: 299px; + top: 25%; + padding: 20px; + border: 1px solid rgb(229, 229, 229); + z-index: -1; +} +.customize-control-widget_form.wide-widget-control.collapsing .widget-inside { + z-index: -2; +} + +.customize-control-widget_form.wide-widget-control .widget-top { + -webkit-transition: background-color 0.4s; + transition: background-color 0.4s; +} +.customize-control-widget_form.wide-widget-control.expanding .widget-top, +.customize-control-widget_form.wide-widget-control.expanded:not(.collapsing) .widget-top { + background-color: rgb(227, 227, 227); +} + +.widget-inside { + padding: 1px 10px 10px 10px; + border-top: none; + line-height: 16px; +} + +.widget-top { + cursor: move; +} + +.customize-control-widget_form.expanded a.widget-action:after { + content: "\f142"; +} + +.customize-control-widget_form.wide-widget-control a.widget-action:after { + content: "\f139"; +} + +.customize-control-widget_form.wide-widget-control.expanded a.widget-action:after { + content: "\f141"; +} + +.widget-title-action { + cursor: pointer; +} + +.customize-control-widget_form .widget .customize-control-title { + cursor: move; +} + +/* @todo What does this do? */ +.control-section.accordion-section.widget-customizer-highlighted > .accordion-section-title, +.customize-control-widget_form.widget-customizer-highlighted { + outline: none; + -webkit-box-shadow: 0 0 3px #ce0000; + box-shadow: 0 0 3px #ce0000; +} + +#widget-customizer-control-templates { + display: none; +} + + +/* MP6-compat */ +#customize-theme-controls .accordion-section-content .widget { + color: black; +} + + +/** +* Widget reordering styles +**/ + +.reorder-toggle { + float: left; + padding: 5px 10px; + margin-left: 10px; + text-decoration: none; + cursor: pointer; + outline: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.reorder-toggle:focus { + outline: 1px dotted; +} + +.reorder-done, +.reordering .reorder { + display: none; +} + +.reordering .reorder-done { + display: block; + color: #aa0000; +} + +#customize-theme-controls .reordering .add-new-widget { + opacity: 0.2; + pointer-events: none; + cursor: not-allowed; +} + +#customize-theme-controls .widget-reorder-nav { + display: none; + float: left; + background-color: #fafafa; +} + +.widget-reorder-nav span { + position: relative; + overflow: hidden; + float: right; + display: block; + width: 33px; /* was 42px for mobile */ + height: 43px; + color: #888; + text-indent: -9999px; + cursor: pointer; + outline: none; +} + +.widget-reorder-nav span:before { + display: inline-block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + font: normal normal 20px/43px 'Genericons'; + text-align: center; + text-indent: 0; +} + +.widget-reorder-nav span:hover, +.widget-reorder-nav span:focus { + color: #444; + background: #eee; +} + +.move-widget:before { + content: '\f442'; +} + +.move-widget-down:before { + content: '\f431'; +} + +.move-widget-up:before { + content: '\f432'; +} + +#customize-theme-controls .first-widget .move-widget-up, +#customize-theme-controls .last-widget .move-widget-down { + color: #d5d5d5; + cursor: default; +} + +#customize-theme-controls .move-widget-area { + display: none; + background: #fff; + border: 1px solid #dedede; + border-top: none; + cursor: auto; +} + +#customize-theme-controls .reordering .move-widget-area.active { + display: block; +} + +#customize-theme-controls .move-widget-area .description { + margin: 0; + padding: 15px 20px; + font-weight: 400; +} + +#customize-theme-controls .widget-area-select { + margin: 0; + padding: 0; + list-style: none; +} + +#customize-theme-controls .widget-area-select li { + position: relative; + margin: 0; + padding: 13px 42px 15px 15px; + color: #555; + border-top: 1px solid #eee; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#customize-theme-controls .widget-area-select li:before { + display: none; + content: '\f418'; + position: absolute; + top: 10px; + right: 10px; + font-family: 'Genericons'; + font-size: 24px; + line-height: 1; +} + +#customize-theme-controls .widget-area-select li:last-child { + border-bottom: 1px solid #eee; +} + +#customize-theme-controls .widget-area-select .selected { + color: #fff; + text-shadow: 0 -1px 0 rgba(0,0,0,.4); + border-top: 1px solid #207fa1; + background: #2ea2cc; +} + +#customize-theme-controls .widget-area-select .selected:before { + display: block; +} + +#customize-theme-controls .widget-area-select .selected:last-child { + border-bottom: 1px solid #207fa1; +} + +#customize-theme-controls .move-widget-actions { + text-align: left; + padding: 12px; +} + +#customize-theme-controls .widget-area-select + li { + border-top: 1px solid #207fa1; +} + +#customize-theme-controls .reordering .widget-title-action { + display: none; +} + +#customize-theme-controls .reordering .widget-reorder-nav { + display: block; +} + + +/** + * Styles for new widget addition panel + */ +.wp-full-overlay-main { + left: auto; /* this overrides a right: 0; which causes the preview to resize, I'd rather have it go off screen at the normal size. */ + width: 100%; +} + +.add-new-widget { + cursor: pointer; + float: left; + -webkit-transition: all 0.2s; + transition: all 0.2s; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -moz-outline: none; + outline: none; +} + +.add-new-widget:before { + content: "\f132"; + display: inline-block; + position: relative; + right: -2px; + top: -1px; + font: normal 16px/1 'dashicons'; + vertical-align: middle; + -webkit-transition: all 0.2s; + transition: all 0.2s; + -webkit-font-smoothing: antialiased; +} + +body.adding-widget .add-new-widget, +body.adding-widget .add-new-widget:hover { + background: #EEE; + border-color: #999; + color: #333; + -webkit-box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5); + box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5); +} +body.adding-widget .add-new-widget:before { + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); +} + +#available-widgets .widget { + position: static; +} + +/* override widgets admin page rules in wp-admin/css/wp-admin.css */ +#widgets-left #available-widgets .widget { + float: none !important; + width: auto !important; +} + +#available-widgets { + position: absolute; + overflow: auto; + top: 0; + bottom: 0; + right: -301px; + width: 300px; + margin: 0; + z-index: 1; + background: #fff; + -webkit-transition: all 0.2s; + transition: all 0.2s; + border-left: 1px solid #dddddd; +} + +#available-widgets-filter { + padding: 8px 13px 7px 17px; + border-bottom: 1px solid #e4e4e4; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +#available-widgets-filter input { + padding: 5px 10px 2px 10px; + width: 100%; +} + +#available-widgets .widget-tpl { + position: relative; + padding: 20px 60px 20px 15px; + border-bottom: 1px solid #e4e4e4; + cursor: pointer; +} + +#available-widgets .widget-tpl:hover, +#available-widgets .widget-tpl.selected { + background: #fafafa; +} + +#available-widgets .widget-top, +#available-widgets .widget-top:hover { + border: none; + background: transparent; + -webkit-box-shadow: none; + box-shadow: none; +} + +#available-widgets .widget-title h4 { + padding: 0 0 5px; + font-size: 14px; +} + +#available-widgets .widget .widget-description { + padding: 0; + color: #777; +} + +#customize-preview { + -webkit-transition: all 0.2s; + transition: all 0.2s; +} + +body.adding-widget #available-widgets { + right: 0; +} + +body.adding-widget .wp-full-overlay-main { + right: 300px; +} + +body.adding-widget #customize-preview { + opacity: 0.4; +} + + +/** Widget Icon styling ** + +* No plurals in naming. +* Ordered from lowest to highest specificity. + +**/ +#available-widgets .widget-title { + position: relative; +} + +#available-widgets .widget-title:before { + content:"\f132"; + position: absolute; + top: -3px; + left: 100%; + margin-left: 20px; + width: 20px; + height: 20px; + color: #333; + font: normal 20px/1 'dashicons', 'widgeticons'; + text-align: center; + -webkit-border-radius: 2px; + border-radius: 2px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-font-smoothing: antialiased; +} + +/* smiley */ +#available-widgets [class*="easy"] .widget-title:before { content: "\f328"; top: -4px; } + +/* star-filled */ +#available-widgets [class*="super"] .widget-title:before, +#available-widgets [class*="like"] .widget-title:before { content: "\f155"; top: -4px; } + +/* wordpress */ +#available-widgets [class*="meta"] .widget-title:before { content: "\f120"; } + +/* archive-box */ +#available-widgets [class*="archives"] .widget-title:before { content: "\f483"; top: -4px; } + +/* category */ +#available-widgets [class*="categor"] .widget-title:before { content: "\f318"; top: -4px; } + +/* comments */ +#available-widgets [class*="comment"] .widget-title:before, +#available-widgets [class*="testimonial"] .widget-title:before, +#available-widgets [class*="chat"] .widget-title:before { content: "\f101"; } + +/* post */ +#available-widgets [class*="post"] .widget-title:before { content: "\f109"; } + +/* admin-page */ +#available-widgets [class*="page"] .widget-title:before { content: "\f105"; } + +/* text */ +#available-widgets [class*="text"] .widget-title:before { content: "\f480"; } + +/* links */ +#available-widgets [class*="link"] .widget-title:before { content: "\f103"; } + +/* search */ +#available-widgets [class*="search"] .widget-title:before { content: "\f179"; } + +/* menu */ +#available-widgets [class*="menu"] .widget-title:before, +#available-widgets [class*="nav"] .widget-title:before { content: "\f333"; } + +/* tag-cloud */ +#available-widgets [class*="tag"] .widget-title:before { content: "\f481"; } + +/* rss */ +#available-widgets [class*="rss"] .widget-title:before { content: "\f303"; top: -6px; } + +/* calendar */ +#available-widgets [class*="event"] .widget-title:before, +#available-widgets [class*="calendar"] .widget-title:before { content: "\f145"; top: -4px;} + +/* format-image */ +#available-widgets [class*="image"] .widget-title:before, +#available-widgets [class*="photo"] .widget-title:before, +#available-widgets [class*="slide"] .widget-title:before, +#available-widgets [class*="instagram"] .widget-title:before { content: "\f128"; } + +/* format-gallery */ +#available-widgets [class*="album"] .widget-title:before, +#available-widgets [class*="galler"] .widget-title:before { content: "\f161"; } + +/* format-video */ +#available-widgets [class*="video"] .widget-title:before, +#available-widgets [class*="tube"] .widget-title:before { content: "\f126"; } + +/* format-audio */ +#available-widgets [class*="music"] .widget-title:before, +#available-widgets [class*="radio"] .widget-title:before, +#available-widgets [class*="audio"] .widget-title:before { content: "\f127"; } + +/* admin-users */ +#available-widgets [class*="login"] .widget-title:before, +#available-widgets [class*="user"] .widget-title:before, +#available-widgets [class*="member"] .widget-title:before, +#available-widgets [class*="avatar"] .widget-title:before, +#available-widgets [class*="subscriber"] .widget-title:before, +#available-widgets [class*="profile"] .widget-title:before, +#available-widgets [class*="grofile"] .widget-title:before { content: "\f110"; } + +/* cart */ +#available-widgets [class*="commerce"] .widget-title:before, +#available-widgets [class*="shop"] .widget-title:before, +#available-widgets [class*="cart"] .widget-title:before { content: "\f174"; top: -4px; } + +/* shield */ +#available-widgets [class*="secur"] .widget-title:before, +#available-widgets [class*="firewall"] .widget-title:before { content: "\f332"; } + +/* chart-bar */ +#available-widgets [class*="analytic"] .widget-title:before, +#available-widgets [class*="stat"] .widget-title:before, +#available-widgets [class*="poll"] .widget-title:before { content: "\f185"; } + +/* feedback */ +#available-widgets [class*="form"] .widget-title:before { content: "\f175"; } + +/* email-alt */ +#available-widgets [class*="subscribe"] .widget-title:before, +#available-widgets [class*="news"] .widget-title:before, +#available-widgets [class*="contact"] .widget-title:before, +#available-widgets [class*="mail"] .widget-title:before { content: "\f466"; } + +/* share */ +#available-widgets [class*="share"] .widget-title:before, +#available-widgets [class*="socia"] .widget-title:before { content: "\f237"; } + +/* translation */ +#available-widgets [class*="lang"] .widget-title:before, +#available-widgets [class*="translat"] .widget-title:before { content: "\f326"; } + +/* location-alt */ +#available-widgets [class*="locat"] .widget-title:before, +#available-widgets [class*="map"] .widget-title:before { content: "\f231"; } + +/* download */ +#available-widgets [class*="download"] .widget-title:before { content: "\f316"; } + +/* cloud */ +#available-widgets [class*="weather"] .widget-title:before { content: "\f176"; top: -4px;} + +/* facebook */ +#available-widgets [class*="facebook"] .widget-title:before { content: "\f304"; } + +/* twitter */ +#available-widgets [class*="tweet"] .widget-title:before, +#available-widgets [class*="twitter"] .widget-title:before { content: "\f301"; } + + +@media screen and (max-height: 700px) and (min-width: 981px) { + .customize-control { + margin-bottom: 0; + } + .widget-top { + -webkit-box-shadow: none; + box-shadow: none; + margin-top: -1px; + } + .widget-top:hover { + position: relative; + z-index: 1; + } + .last-widget { + margin-bottom: 15px; + } + .widget-title h4 { + padding: 13px 15px; + } + .widget-top a.widget-action:after { + padding-top: 9px; + } + .widget-reorder-nav span { + height: 39px; + } + .widget-reorder-nav span:before { + line-height: 39px; + } + #customize-theme-controls .widget-area-select li { + padding: 9px 42px 11px 15px; + } + #customize-theme-controls .widget-area-select li:before { + top: 6px; + } +} diff --git a/wp-admin/css/customize-widgets.css b/wp-admin/css/customize-widgets.css new file mode 100644 index 0000000000..6064165d81 --- /dev/null +++ b/wp-admin/css/customize-widgets.css @@ -0,0 +1,623 @@ +.wp-full-overlay-sidebar { + overflow: visible; +} + +/** + * Hide all sidebar sections by default, only show them (via JS) once the + * preview loads and we know whether the sidebars are used in the template. + */ + +.control-section[id^="accordion-section-sidebar-widgets-"], +.customize-control-sidebar_widgets label, +.customize-control-sidebar_widgets .hide-if-js { + /* The link in .customize-control-sidebar_widgets .hide-if-js will fail if it ever gets used. */ + display:none; +} + +.customize-control-widget_form .widget-top { + -webkit-transition: opacity 0.5s; + transition: opacity 0.5s; +} + +.customize-control-widget_form:not(.widget-rendered) .widget-top { + opacity: 0.5; +} + + +.customize-control-widget_form .widget-control-save { + display: none; +} + +.customize-control-widget_form .spinner { + display: inline; + opacity: 0.0; + -webkit-transition: opacity 0.1s; + transition: opacity 0.1s; +} +.customize-control-widget_form.previewer-loading .spinner { + opacity: 1.0; +} + +.customize-control-widget_form .widget { + margin-bottom: 0; +} + +.customize-control-widget_form:not(.wide-widget-control) { + /** + * Prevent plugins (e.g. Widget Visibility in Jetpack) from forcing widget forms + * to be wide and so overflow the customizer panel + */ + left: auto !important; + max-width: 100%; +} +.customize-control-widget_form.wide-widget-control .widget-inside { + position: fixed; + left: 299px; + top: 25%; + padding: 20px; + border: 1px solid rgb(229, 229, 229); + z-index: -1; +} +.customize-control-widget_form.wide-widget-control.collapsing .widget-inside { + z-index: -2; +} + +.customize-control-widget_form.wide-widget-control .widget-top { + -webkit-transition: background-color 0.4s; + transition: background-color 0.4s; +} +.customize-control-widget_form.wide-widget-control.expanding .widget-top, +.customize-control-widget_form.wide-widget-control.expanded:not(.collapsing) .widget-top { + background-color: rgb(227, 227, 227); +} + +.widget-inside { + padding: 1px 10px 10px 10px; + border-top: none; + line-height: 16px; +} + +.widget-top { + cursor: move; +} + +.customize-control-widget_form.expanded a.widget-action:after { + content: "\f142"; +} + +.customize-control-widget_form.wide-widget-control a.widget-action:after { + content: "\f139"; +} + +.customize-control-widget_form.wide-widget-control.expanded a.widget-action:after { + content: "\f141"; +} + +.widget-title-action { + cursor: pointer; +} + +.customize-control-widget_form .widget .customize-control-title { + cursor: move; +} + +/* @todo What does this do? */ +.control-section.accordion-section.widget-customizer-highlighted > .accordion-section-title, +.customize-control-widget_form.widget-customizer-highlighted { + outline: none; + -webkit-box-shadow: 0 0 3px #ce0000; + box-shadow: 0 0 3px #ce0000; +} + +#widget-customizer-control-templates { + display: none; +} + + +/* MP6-compat */ +#customize-theme-controls .accordion-section-content .widget { + color: black; +} + + +/** +* Widget reordering styles +**/ + +.reorder-toggle { + float: right; + padding: 5px 10px; + margin-right: 10px; + text-decoration: none; + cursor: pointer; + outline: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.reorder-toggle:focus { + outline: 1px dotted; +} + +.reorder-done, +.reordering .reorder { + display: none; +} + +.reordering .reorder-done { + display: block; + color: #aa0000; +} + +#customize-theme-controls .reordering .add-new-widget { + opacity: 0.2; + pointer-events: none; + cursor: not-allowed; +} + +#customize-theme-controls .widget-reorder-nav { + display: none; + float: right; + background-color: #fafafa; +} + +.widget-reorder-nav span { + position: relative; + overflow: hidden; + float: left; + display: block; + width: 33px; /* was 42px for mobile */ + height: 43px; + color: #888; + text-indent: -9999px; + cursor: pointer; + outline: none; +} + +.widget-reorder-nav span:before { + display: inline-block; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + font: normal normal 20px/43px 'Genericons'; + text-align: center; + text-indent: 0; +} + +.widget-reorder-nav span:hover, +.widget-reorder-nav span:focus { + color: #444; + background: #eee; +} + +.move-widget:before { + content: '\f442'; +} + +.move-widget-down:before { + content: '\f431'; +} + +.move-widget-up:before { + content: '\f432'; +} + +#customize-theme-controls .first-widget .move-widget-up, +#customize-theme-controls .last-widget .move-widget-down { + color: #d5d5d5; + cursor: default; +} + +#customize-theme-controls .move-widget-area { + display: none; + background: #fff; + border: 1px solid #dedede; + border-top: none; + cursor: auto; +} + +#customize-theme-controls .reordering .move-widget-area.active { + display: block; +} + +#customize-theme-controls .move-widget-area .description { + margin: 0; + padding: 15px 20px; + font-weight: 400; +} + +#customize-theme-controls .widget-area-select { + margin: 0; + padding: 0; + list-style: none; +} + +#customize-theme-controls .widget-area-select li { + position: relative; + margin: 0; + padding: 13px 15px 15px 42px; + color: #555; + border-top: 1px solid #eee; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#customize-theme-controls .widget-area-select li:before { + display: none; + content: '\f418'; + position: absolute; + top: 10px; + left: 10px; + font-family: 'Genericons'; + font-size: 24px; + line-height: 1; +} + +#customize-theme-controls .widget-area-select li:last-child { + border-bottom: 1px solid #eee; +} + +#customize-theme-controls .widget-area-select .selected { + color: #fff; + text-shadow: 0 -1px 0 rgba(0,0,0,.4); + border-top: 1px solid #207fa1; + background: #2ea2cc; +} + +#customize-theme-controls .widget-area-select .selected:before { + display: block; +} + +#customize-theme-controls .widget-area-select .selected:last-child { + border-bottom: 1px solid #207fa1; +} + +#customize-theme-controls .move-widget-actions { + text-align: right; + padding: 12px; +} + +#customize-theme-controls .widget-area-select + li { + border-top: 1px solid #207fa1; +} + +#customize-theme-controls .reordering .widget-title-action { + display: none; +} + +#customize-theme-controls .reordering .widget-reorder-nav { + display: block; +} + + +/** + * Styles for new widget addition panel + */ +.wp-full-overlay-main { + right: auto; /* this overrides a right: 0; which causes the preview to resize, I'd rather have it go off screen at the normal size. */ + width: 100%; +} + +.add-new-widget { + cursor: pointer; + float: right; + -webkit-transition: all 0.2s; + transition: all 0.2s; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -moz-outline: none; + outline: none; +} + +.add-new-widget:before { + content: "\f132"; + display: inline-block; + position: relative; + left: -2px; + top: -1px; + font: normal 16px/1 'dashicons'; + vertical-align: middle; + -webkit-transition: all 0.2s; + transition: all 0.2s; + -webkit-font-smoothing: antialiased; +} + +body.adding-widget .add-new-widget, +body.adding-widget .add-new-widget:hover { + background: #EEE; + border-color: #999; + color: #333; + -webkit-box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5); + box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5); +} +body.adding-widget .add-new-widget:before { + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); +} + +#available-widgets .widget { + position: static; +} + +/* override widgets admin page rules in wp-admin/css/wp-admin.css */ +#widgets-left #available-widgets .widget { + float: none !important; + width: auto !important; +} + +#available-widgets { + position: absolute; + overflow: auto; + top: 0; + bottom: 0; + left: -301px; + width: 300px; + margin: 0; + z-index: 1; + background: #fff; + -webkit-transition: all 0.2s; + transition: all 0.2s; + border-right: 1px solid #dddddd; +} + +#available-widgets-filter { + padding: 8px 17px 7px 13px; + border-bottom: 1px solid #e4e4e4; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +#available-widgets-filter input { + padding: 5px 10px 2px 10px; + width: 100%; +} + +#available-widgets .widget-tpl { + position: relative; + padding: 20px 15px 20px 60px; + border-bottom: 1px solid #e4e4e4; + cursor: pointer; +} + +#available-widgets .widget-tpl:hover, +#available-widgets .widget-tpl.selected { + background: #fafafa; +} + +#available-widgets .widget-top, +#available-widgets .widget-top:hover { + border: none; + background: transparent; + -webkit-box-shadow: none; + box-shadow: none; +} + +#available-widgets .widget-title h4 { + padding: 0 0 5px; + font-size: 14px; +} + +#available-widgets .widget .widget-description { + padding: 0; + color: #777; +} + +#customize-preview { + -webkit-transition: all 0.2s; + transition: all 0.2s; +} + +body.adding-widget #available-widgets { + left: 0; +} + +body.adding-widget .wp-full-overlay-main { + left: 300px; +} + +body.adding-widget #customize-preview { + opacity: 0.4; +} + + +/** Widget Icon styling ** + +* No plurals in naming. +* Ordered from lowest to highest specificity. + +**/ +#available-widgets .widget-title { + position: relative; +} + +#available-widgets .widget-title:before { + content:"\f132"; + position: absolute; + top: -3px; + right: 100%; + margin-right: 20px; + width: 20px; + height: 20px; + color: #333; + font: normal 20px/1 'dashicons', 'widgeticons'; + text-align: center; + -webkit-border-radius: 2px; + border-radius: 2px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-font-smoothing: antialiased; +} + +/* smiley */ +#available-widgets [class*="easy"] .widget-title:before { content: "\f328"; top: -4px; } + +/* star-filled */ +#available-widgets [class*="super"] .widget-title:before, +#available-widgets [class*="like"] .widget-title:before { content: "\f155"; top: -4px; } + +/* wordpress */ +#available-widgets [class*="meta"] .widget-title:before { content: "\f120"; } + +/* archive-box */ +#available-widgets [class*="archives"] .widget-title:before { content: "\f483"; top: -4px; } + +/* category */ +#available-widgets [class*="categor"] .widget-title:before { content: "\f318"; top: -4px; } + +/* comments */ +#available-widgets [class*="comment"] .widget-title:before, +#available-widgets [class*="testimonial"] .widget-title:before, +#available-widgets [class*="chat"] .widget-title:before { content: "\f101"; } + +/* post */ +#available-widgets [class*="post"] .widget-title:before { content: "\f109"; } + +/* admin-page */ +#available-widgets [class*="page"] .widget-title:before { content: "\f105"; } + +/* text */ +#available-widgets [class*="text"] .widget-title:before { content: "\f480"; } + +/* links */ +#available-widgets [class*="link"] .widget-title:before { content: "\f103"; } + +/* search */ +#available-widgets [class*="search"] .widget-title:before { content: "\f179"; } + +/* menu */ +#available-widgets [class*="menu"] .widget-title:before, +#available-widgets [class*="nav"] .widget-title:before { content: "\f333"; } + +/* tag-cloud */ +#available-widgets [class*="tag"] .widget-title:before { content: "\f481"; } + +/* rss */ +#available-widgets [class*="rss"] .widget-title:before { content: "\f303"; top: -6px; } + +/* calendar */ +#available-widgets [class*="event"] .widget-title:before, +#available-widgets [class*="calendar"] .widget-title:before { content: "\f145"; top: -4px;} + +/* format-image */ +#available-widgets [class*="image"] .widget-title:before, +#available-widgets [class*="photo"] .widget-title:before, +#available-widgets [class*="slide"] .widget-title:before, +#available-widgets [class*="instagram"] .widget-title:before { content: "\f128"; } + +/* format-gallery */ +#available-widgets [class*="album"] .widget-title:before, +#available-widgets [class*="galler"] .widget-title:before { content: "\f161"; } + +/* format-video */ +#available-widgets [class*="video"] .widget-title:before, +#available-widgets [class*="tube"] .widget-title:before { content: "\f126"; } + +/* format-audio */ +#available-widgets [class*="music"] .widget-title:before, +#available-widgets [class*="radio"] .widget-title:before, +#available-widgets [class*="audio"] .widget-title:before { content: "\f127"; } + +/* admin-users */ +#available-widgets [class*="login"] .widget-title:before, +#available-widgets [class*="user"] .widget-title:before, +#available-widgets [class*="member"] .widget-title:before, +#available-widgets [class*="avatar"] .widget-title:before, +#available-widgets [class*="subscriber"] .widget-title:before, +#available-widgets [class*="profile"] .widget-title:before, +#available-widgets [class*="grofile"] .widget-title:before { content: "\f110"; } + +/* cart */ +#available-widgets [class*="commerce"] .widget-title:before, +#available-widgets [class*="shop"] .widget-title:before, +#available-widgets [class*="cart"] .widget-title:before { content: "\f174"; top: -4px; } + +/* shield */ +#available-widgets [class*="secur"] .widget-title:before, +#available-widgets [class*="firewall"] .widget-title:before { content: "\f332"; } + +/* chart-bar */ +#available-widgets [class*="analytic"] .widget-title:before, +#available-widgets [class*="stat"] .widget-title:before, +#available-widgets [class*="poll"] .widget-title:before { content: "\f185"; } + +/* feedback */ +#available-widgets [class*="form"] .widget-title:before { content: "\f175"; } + +/* email-alt */ +#available-widgets [class*="subscribe"] .widget-title:before, +#available-widgets [class*="news"] .widget-title:before, +#available-widgets [class*="contact"] .widget-title:before, +#available-widgets [class*="mail"] .widget-title:before { content: "\f466"; } + +/* share */ +#available-widgets [class*="share"] .widget-title:before, +#available-widgets [class*="socia"] .widget-title:before { content: "\f237"; } + +/* translation */ +#available-widgets [class*="lang"] .widget-title:before, +#available-widgets [class*="translat"] .widget-title:before { content: "\f326"; } + +/* location-alt */ +#available-widgets [class*="locat"] .widget-title:before, +#available-widgets [class*="map"] .widget-title:before { content: "\f231"; } + +/* download */ +#available-widgets [class*="download"] .widget-title:before { content: "\f316"; } + +/* cloud */ +#available-widgets [class*="weather"] .widget-title:before { content: "\f176"; top: -4px;} + +/* facebook */ +#available-widgets [class*="facebook"] .widget-title:before { content: "\f304"; } + +/* twitter */ +#available-widgets [class*="tweet"] .widget-title:before, +#available-widgets [class*="twitter"] .widget-title:before { content: "\f301"; } + + +@media screen and (max-height: 700px) and (min-width: 981px) { + .customize-control { + margin-bottom: 0; + } + .widget-top { + -webkit-box-shadow: none; + box-shadow: none; + margin-top: -1px; + } + .widget-top:hover { + position: relative; + z-index: 1; + } + .last-widget { + margin-bottom: 15px; + } + .widget-title h4 { + padding: 13px 15px; + } + .widget-top a.widget-action:after { + padding-top: 9px; + } + .widget-reorder-nav span { + height: 39px; + } + .widget-reorder-nav span:before { + line-height: 39px; + } + #customize-theme-controls .widget-area-select li { + padding: 9px 15px 11px 42px; + } + #customize-theme-controls .widget-area-select li:before { + top: 6px; + } +} diff --git a/wp-admin/includes/ajax-actions.php b/wp-admin/includes/ajax-actions.php index d89de1a06b..788bd0cf0b 100644 --- a/wp-admin/includes/ajax-actions.php +++ b/wp-admin/includes/ajax-actions.php @@ -1587,6 +1587,13 @@ function wp_ajax_save_widget() { wp_die(); } +function wp_ajax_update_widget() { + require( ABSPATH . WPINC . '/class-wp-customize-manager.php' ); + $GLOBALS['wp_customize'] = new WP_Customize_Manager; + + WP_Customize_Widgets::wp_ajax_update_widget(); +} + function wp_ajax_upload_attachment() { check_ajax_referer( 'media-form' ); diff --git a/wp-admin/js/customize-widgets.js b/wp-admin/js/customize-widgets.js new file mode 100644 index 0000000000..290142d481 --- /dev/null +++ b/wp-admin/js/customize-widgets.js @@ -0,0 +1,1733 @@ +/*global wp, Backbone, _, jQuery, WidgetCustomizer_exports */ +/*exported WidgetCustomizer */ +var WidgetCustomizer = ( function ($) { + 'use strict'; + + var customize = wp.customize; + var self = { + update_widget_ajax_action: null, + update_widget_nonce_value: null, + update_widget_nonce_post_key: null, + i18n: { + save_btn_label: '', + save_btn_tooltip: '', + remove_btn_label: '', + remove_btn_tooltip: '' + }, + available_widgets: [], // available widgets for instantiating + registered_widgets: [], // all widgets registered + active_sidebar_control: null, + previewer: null, + saved_widget_ids: {}, + registered_sidebars: [], + tpl: { + move_widget_area: '', + widget_reorder_nav: '' + } + }; + $.extend( self, WidgetCustomizer_exports ); + + // Lots of widgets expect this old ajaxurl global to be available + if ( typeof window.ajaxurl === 'undefined' ) { + window.ajaxurl = wp.ajax.settings.url; + } + + // Unfortunately many widgets try to look for instances under div#widgets-right, + // so we have to add that ID to a container div in the customizer for compat + $( '#customize-theme-controls' ).closest( 'div:not([id])' ).attr( 'id', 'widgets-right' ); + + /** + * Set up model + */ + var Widget = self.Widget = Backbone.Model.extend( { + id: null, + temp_id: null, + classname: null, + control_tpl: null, + description: null, + is_disabled: null, + is_multi: null, + multi_number: null, + name: null, + id_base: null, + transport: 'refresh', + params: [], + width: null, + height: null + } ); + var WidgetCollection = self.WidgetCollection = Backbone.Collection.extend( { + model: Widget + } ); + self.available_widgets = new WidgetCollection( self.available_widgets ); + + var Sidebar = self.Sidebar = Backbone.Model.extend( { + after_title: null, + after_widget: null, + before_title: null, + before_widget: null, + 'class': null, + description: null, + id: null, + name: null, + is_rendered: false + } ); + var SidebarCollection = self.SidebarCollection = Backbone.Collection.extend( { + model: Sidebar + } ); + self.registered_sidebars = new SidebarCollection( self.registered_sidebars ); + + /** + * On DOM ready, initialize some meta functionality independent of specific + * customizer controls. + */ + self.init = function () { + this.showFirstSidebarIfRequested(); + this.availableWidgetsPanel.setup(); + }; + wp.customize.bind( 'ready', function () { + self.init(); + } ); + + /** + * Listen for updates to which sidebars are rendered in the preview and toggle + * the customizer sections accordingly. + */ + self.showFirstSidebarIfRequested = function () { + if ( ! /widget-customizer=open/.test( location.search ) ) { + return; + } + + var show_first_visible_sidebar = function () { + self.registered_sidebars.off( 'change:is_rendered', show_first_visible_sidebar ); + var first_rendered_sidebar = self.registered_sidebars.find( function ( sidebar ) { + return sidebar.get( 'is_rendered' ); + } ); + if ( ! first_rendered_sidebar ) { + return; + } + var section = $( '#accordion-section-sidebar-widgets-' + first_rendered_sidebar.get( 'id' ) ); + if ( ! section.hasClass( 'open' ) ) { + section.find( '.accordion-section-title' ).trigger( 'click' ); + } + section[0].scrollIntoView(); + }; + show_first_visible_sidebar = _.debounce( show_first_visible_sidebar, 100 ); // so only fires when all updated at end + self.registered_sidebars.on( 'change:is_rendered', show_first_visible_sidebar ); + }; + + /** + * Sidebar Widgets control + * Note that 'sidebar_widgets' must match the Sidebar_Widgets_WP_Customize_Control::$type + */ + customize.controlConstructor.sidebar_widgets = customize.Control.extend( { + + /** + * Set up the control + */ + ready: function() { + var control = this; + control.control_section = control.container.closest( '.control-section' ); + control.section_content = control.container.closest( '.accordion-section-content' ); + control._setupModel(); + control._setupSortable(); + control._setupAddition(); + control._applyCardinalOrderClassNames(); + }, + + /** + * Update ordering of widget control forms when the setting is updated + */ + _setupModel: function() { + var control = this; + var registered_sidebar = self.registered_sidebars.get( control.params.sidebar_id ); + + control.setting.bind( function( new_widget_ids, old_widget_ids ) { + var removed_widget_ids = _( old_widget_ids ).difference( new_widget_ids ); + + // Filter out any persistent widget_ids for widgets which have been deactivated + new_widget_ids = _( new_widget_ids ).filter( function ( new_widget_id ) { + var parsed_widget_id = parse_widget_id( new_widget_id ); + return !! self.available_widgets.findWhere( { id_base: parsed_widget_id.id_base } ); + } ); + + var widget_form_controls = _( new_widget_ids ).map( function ( widget_id ) { + var widget_form_control = self.getWidgetFormControlForWidget( widget_id ); + if ( ! widget_form_control ) { + widget_form_control = control.addWidget( widget_id ); + } + return widget_form_control; + } ); + + // Sort widget controls to their new positions + widget_form_controls.sort( function ( a, b ) { + var a_index = new_widget_ids.indexOf( a.params.widget_id ); + var b_index = new_widget_ids.indexOf( b.params.widget_id ); + if ( a_index === b_index ) { + return 0; + } + return a_index < b_index ? -1 : 1; + } ); + + var sidebar_widgets_add_control = control.section_content.find( '.customize-control-sidebar_widgets' ); + + // Append the controls to put them in the right order + var final_control_containers = _( widget_form_controls ).map( function( widget_form_controls ) { + return widget_form_controls.container[0]; + } ); + + // Re-sort widget form controls (including widgets form other sidebars newly moved here) + sidebar_widgets_add_control.before( final_control_containers ); + control._applyCardinalOrderClassNames(); + + // If the widget was dragged into the sidebar, make sure the sidebar_id param is updated + _( widget_form_controls ).each( function ( widget_form_control ) { + widget_form_control.params.sidebar_id = control.params.sidebar_id; + } ); + + // Cleanup after widget removal + _( removed_widget_ids ).each( function ( removed_widget_id ) { + + // Using setTimeout so that when moving a widget to another sidebar, the other sidebars_widgets settings get a chance to update + setTimeout( function () { + var is_present_in_another_sidebar = false; + + // Check if the widget is in another sidebar + wp.customize.each( function ( other_setting ) { + if ( other_setting.id === control.setting.id || 0 !== other_setting.id.indexOf( 'sidebars_widgets[' ) || other_setting.id === 'sidebars_widgets[wp_inactive_widgets]' ) { + return; + } + var other_sidebar_widgets = other_setting(); + var i = other_sidebar_widgets.indexOf( removed_widget_id ); + if ( -1 !== i ) { + is_present_in_another_sidebar = true; + } + } ); + + // If the widget is present in another sidebar, abort! + if ( is_present_in_another_sidebar ) { + return; + } + + var removed_control = self.getWidgetFormControlForWidget( removed_widget_id ); + + // Detect if widget control was dragged to another sidebar + var was_dragged_to_another_sidebar = ( + removed_control && + $.contains( document, removed_control.container[0] ) && + ! $.contains( control.section_content[0], removed_control.container[0] ) + ); + + // Delete any widget form controls for removed widgets + if ( removed_control && ! was_dragged_to_another_sidebar ) { + wp.customize.control.remove( removed_control.id ); + removed_control.container.remove(); + } + + // Move widget to inactive widgets sidebar (move it to trash) if has been previously saved + // This prevents the inactive widgets sidebar from overflowing with throwaway widgets + if ( self.saved_widget_ids[removed_widget_id] ) { + var inactive_widgets = wp.customize.value( 'sidebars_widgets[wp_inactive_widgets]' )().slice(); + inactive_widgets.push( removed_widget_id ); + wp.customize.value( 'sidebars_widgets[wp_inactive_widgets]' )( _( inactive_widgets ).unique() ); + } + + // Make old single widget available for adding again + var removed_id_base = parse_widget_id( removed_widget_id ).id_base; + var widget = self.available_widgets.findWhere( { id_base: removed_id_base } ); + if ( widget && ! widget.get( 'is_multi' ) ) { + widget.set( 'is_disabled', false ); + } + } ); + + } ); + } ); + + // Update the model with whether or not the sidebar is rendered + self.previewer.bind( 'rendered-sidebars', function ( rendered_sidebars ) { + var is_rendered = !! rendered_sidebars[control.params.sidebar_id]; + registered_sidebar.set( 'is_rendered', is_rendered ); + } ); + + // Show the sidebar section when it becomes visible + registered_sidebar.on( 'change:is_rendered', function ( ) { + var section_selector = '#accordion-section-sidebar-widgets-' + this.get( 'id' ); + var section = $( section_selector ); + if ( this.get( 'is_rendered' ) ) { + section.stop().slideDown( function () { + $( this ).css( 'height', 'auto' ); // so that the .accordion-section-content won't overflow + } ); + } else { + // Make sure that hidden sections get closed first + if ( section.hasClass( 'open' ) ) { + // it would be nice if accordionSwitch() in accordion.js was public + section.find( '.accordion-section-title' ).trigger( 'click' ); + } + section.stop().slideUp(); + } + } ); + }, + + /** + * Allow widgets in sidebar to be re-ordered, and for the order to be previewed + */ + _setupSortable: function () { + var control = this; + control.is_reordering = false; + + /** + * Update widget order setting when controls are re-ordered + */ + control.section_content.sortable( { + items: '> .customize-control-widget_form', + handle: '.widget-top', + axis: 'y', + connectWith: '.accordion-section-content:has(.customize-control-sidebar_widgets)', + update: function () { + var widget_container_ids = control.section_content.sortable( 'toArray' ); + var widget_ids = $.map( widget_container_ids, function ( widget_container_id ) { + return $( '#' + widget_container_id ).find( ':input[name=widget-id]' ).val(); + } ); + control.setting( widget_ids ); + } + } ); + + /** + * Expand other customizer sidebar section when dragging a control widget over it, + * allowing the control to be dropped into another section + */ + control.control_section.find( '.accordion-section-title' ).droppable( { + accept: '.customize-control-widget_form', + over: function () { + if ( ! control.control_section.hasClass( 'open' ) ) { + control.control_section.addClass( 'open' ); + control.section_content.toggle( false ).slideToggle( 150, function () { + control.section_content.sortable( 'refreshPositions' ); + } ); + } + } + } ); + + /** + * Keyboard-accessible reordering + */ + control.container.find( '.reorder-toggle' ).on( 'click keydown', function( event ) { + if ( event.type === 'keydown' && ! ( event.which === 13 || event.which === 32 ) ) { // Enter or Spacebar + return; + } + + control.toggleReordering( ! control.is_reordering ); + } ); + }, + + /** + * Set up UI for adding a new widget + */ + _setupAddition: function () { + var control = this; + + control.container.find( '.add-new-widget' ).on( 'click keydown', function( event ) { + if ( event.type === 'keydown' && ! ( event.which === 13 || event.which === 32 ) ) { // Enter or Spacebar + return; + } + + if ( control.section_content.hasClass( 'reordering' ) ) { + return; + } + + // @todo Use an control.is_adding state + if ( ! $( 'body' ).hasClass( 'adding-widget' ) ) { + self.availableWidgetsPanel.open( control ); + } else { + self.availableWidgetsPanel.close(); + } + } ); + }, + + /** + * Add classes to the widget_form controls to assist with styling + */ + _applyCardinalOrderClassNames: function () { + var control = this; + control.section_content.find( '.customize-control-widget_form' ) + .removeClass( 'first-widget' ) + .removeClass( 'last-widget' ) + .find( '.move-widget-down, .move-widget-up' ).prop( 'tabIndex', 0 ); + + control.section_content.find( '.customize-control-widget_form:first' ) + .addClass( 'first-widget' ) + .find( '.move-widget-up' ).prop( 'tabIndex', -1 ); + control.section_content.find( '.customize-control-widget_form:last' ) + .addClass( 'last-widget' ) + .find( '.move-widget-down' ).prop( 'tabIndex', -1 ); + }, + + + /*********************************************************************** + * Begin public API methods + **********************************************************************/ + + /** + * Enable/disable the reordering UI + * + * @param {Boolean} toggle to enable/disable reordering + */ + toggleReordering: function ( toggle ) { + var control = this; + toggle = Boolean( toggle ); + if ( toggle === control.section_content.hasClass( 'reordering' ) ) { + return; + } + + control.is_reordering = toggle; + control.section_content.toggleClass( 'reordering', toggle ); + + if ( toggle ) { + _( control.getWidgetFormControls() ).each( function ( form_control ) { + form_control.collapseForm(); + } ); + } + }, + + /** + * @return {wp.customize.controlConstructor.widget_form[]} + */ + getWidgetFormControls: function () { + var control = this; + var form_controls = _( control.setting() ).map( function ( widget_id ) { + var setting_id = widget_id_to_setting_id( widget_id ); + var form_control = customize.control( setting_id ); + if ( ! form_control ) { + throw new Error( 'Unable to find widget_form control for ' + widget_id ); + } + return form_control; + } ); + return form_controls; + }, + + /** + * @param {string} widget_id or an id_base for adding a previously non-existing widget + * @returns {object} widget_form control instance + */ + addWidget: function ( widget_id ) { + var control = this; + var parsed_widget_id = parse_widget_id( widget_id ); + var widget_number = parsed_widget_id.number; + var widget_id_base = parsed_widget_id.id_base; + var widget = self.available_widgets.findWhere( {id_base: widget_id_base} ); + if ( ! widget ) { + throw new Error( 'Widget unexpectedly not found.' ); + } + if ( widget_number && ! widget.get( 'is_multi' ) ) { + throw new Error( 'Did not expect a widget number to be supplied for a non-multi widget' ); + } + + // Set up new multi widget + if ( widget.get( 'is_multi' ) && ! widget_number ) { + widget.set( 'multi_number', widget.get( 'multi_number' ) + 1 ); + widget_number = widget.get( 'multi_number' ); + } + + var control_html = $( '#widget-tpl-' + widget.get( 'id' ) ).html(); + if ( widget.get( 'is_multi' ) ) { + control_html = control_html.replace( /<[^<>]+>/g, function ( m ) { + return m.replace( /__i__|%i%/g, widget_number ); + } ); + } else { + widget.set( 'is_disabled', true ); // Prevent single widget from being added again now + } + + var customize_control_type = 'widget_form'; + var customize_control = $( '
  • ' ); + customize_control.addClass( 'customize-control' ); + customize_control.addClass( 'customize-control-' + customize_control_type ); + customize_control.append( $( control_html ) ); + customize_control.find( '> .widget-icon' ).remove(); + if ( widget.get( 'is_multi' ) ) { + customize_control.find( 'input[name="widget_number"]' ).val( widget_number ); + customize_control.find( 'input[name="multi_number"]' ).val( widget_number ); + } + widget_id = customize_control.find( '[name="widget-id"]' ).val(); + customize_control.hide(); // to be slid-down below + + var setting_id = 'widget_' + widget.get( 'id_base' ); + if ( widget.get( 'is_multi' ) ) { + setting_id += '[' + widget_number + ']'; + } + customize_control.attr( 'id', 'customize-control-' + setting_id.replace( /\]/g, '' ).replace( /\[/g, '-' ) ); + + control.container.after( customize_control ); + + // Only create setting if it doesn't already exist (if we're adding a pre-existing inactive widget) + var is_existing_widget = wp.customize.has( setting_id ); + if ( ! is_existing_widget ) { + var setting_args = { + transport: 'refresh', + previewer: control.setting.previewer + }; + wp.customize.create( setting_id, setting_id, {}, setting_args ); + } + + var Constructor = wp.customize.controlConstructor[customize_control_type]; + var widget_form_control = new Constructor( setting_id, { + params: { + settings: { + 'default': setting_id + }, + sidebar_id: control.params.sidebar_id, + widget_id: widget_id, + widget_id_base: widget.get( 'id_base' ), + type: customize_control_type, + is_new: ! is_existing_widget, + width: widget.get( 'width' ), + height: widget.get( 'height' ), + is_wide: widget.get( 'is_wide' ) + }, + previewer: control.setting.previewer + } ); + wp.customize.control.add( setting_id, widget_form_control ); + + // Make sure widget is removed from the other sidebars + wp.customize.each( function ( other_setting ) { + if ( other_setting.id === control.setting.id ) { + return; + } + if ( 0 !== other_setting.id.indexOf( 'sidebars_widgets[' ) ) { + return; + } + var other_sidebar_widgets = other_setting().slice(); + var i = other_sidebar_widgets.indexOf( widget_id ); + if ( -1 !== i ) { + other_sidebar_widgets.splice( i ); + other_setting( other_sidebar_widgets ); + } + } ); + + // Add widget to this sidebar + var sidebar_widgets = control.setting().slice(); + if ( -1 === sidebar_widgets.indexOf( widget_id ) ) { + sidebar_widgets.push( widget_id ); + control.setting( sidebar_widgets ); + } + + customize_control.slideDown( function () { + if ( is_existing_widget ) { + widget_form_control.expandForm(); + widget_form_control.updateWidget( { + instance: widget_form_control.setting(), + complete: function ( error ) { + if ( error ) { + throw error; + } + widget_form_control.focus(); + } + } ); + } else { + widget_form_control.focus(); + } + } ); + + return widget_form_control; + } + + } ); + + /** + * Widget Form control + * Note that 'widget_form' must match the Widget_Form_WP_Customize_Control::$type + */ + customize.controlConstructor.widget_form = customize.Control.extend( { + + /** + * Set up the control + */ + ready: function() { + var control = this; + control._setupModel(); + control._setupWideWidget(); + control._setupControlToggle(); + control._setupWidgetTitle(); + control._setupReorderUI(); + control._setupHighlightEffects(); + control._setupUpdateUI(); + control._setupRemoveUI(); + control.hook( 'init' ); + }, + + /** + * Hooks for widgets to support living in the customizer control + */ + hooks: { + _default: {}, + rss: { + formUpdated: function ( serialized_form ) { + var control = this; + var old_widget_error = control.container.find( '.widget-error:first' ); + var new_widget_error = serialized_form.find( '.widget-error:first' ); + if ( old_widget_error.length && new_widget_error.length ) { + old_widget_error.replaceWith( new_widget_error ); + } else if ( old_widget_error.length ) { + old_widget_error.remove(); + } else if ( new_widget_error.length ) { + control.container.find( '.widget-content' ).prepend( new_widget_error ); + } + } + } + }, + + /** + * Trigger an 'action' which a specific widget type can handle + * + * @param name + */ + hook: function ( name ) { + var args = Array.prototype.slice.call( arguments, 1 ); + var handler; + if ( this.hooks[this.params.widget_id_base] && this.hooks[this.params.widget_id_base][name] ) { + handler = this.hooks[this.params.widget_id_base][name]; + } else if ( this.hooks._default[name] ) { + handler = this.hooks._default[name]; + } + if ( handler ) { + handler.apply( this, args ); + } + }, + + /** + * Handle changes to the setting + */ + _setupModel: function () { + var control = this; + + // Remember saved widgets so we know which to trash (move to inactive widgets sidebar) + var remember_saved_widget_id = function () { + self.saved_widget_ids[control.params.widget_id] = true; + }; + wp.customize.bind( 'ready', remember_saved_widget_id ); + wp.customize.bind( 'saved', remember_saved_widget_id ); + + control._update_count = 0; + control.is_widget_updating = false; + + // Update widget whenever model changes + control.setting.bind( function( to, from ) { + if ( ! _( from ).isEqual( to ) && ! control.is_widget_updating ) { + control.updateWidget( { instance: to } ); + } + } ); + }, + + /** + * Add special behaviors for wide widget controls + */ + _setupWideWidget: function () { + var control = this; + if ( ! control.params.is_wide ) { + return; + } + var widget_inside = control.container.find( '.widget-inside' ); + var customize_sidebar = $( '.wp-full-overlay-sidebar-content:first' ); + control.container.addClass( 'wide-widget-control' ); + + control.container.find( '.widget-content:first' ).css( { + 'min-width': control.params.width, + 'min-height': control.params.height + } ); + + /** + * Keep the widget-inside positioned so the top of fixed-positioned + * element is at the same top position as the widget-top. When the + * widget-top is scrolled out of view, keep the widget-top in view; + * likewise, don't allow the widget to drop off the bottom of the window. + */ + var position_widget = function () { + var offset_top = control.container.offset().top; + var height = widget_inside.outerHeight(); + var top = Math.max( offset_top, 0 ); + var max_top = $( window ).height() - height; + top = Math.min( top, max_top ); + widget_inside.css( 'top', top ); + }; + + var theme_controls_container = $( '#customize-theme-controls' ); + control.container.on( 'expand', function () { + customize_sidebar.on( 'scroll', position_widget ); + $( window ).on( 'resize', position_widget ); + theme_controls_container.on( 'expanded collapsed', position_widget ); + position_widget(); + } ); + control.container.on( 'collapsed', function () { + customize_sidebar.off( 'scroll', position_widget ); + theme_controls_container.off( 'expanded collapsed', position_widget ); + $( window ).off( 'resize', position_widget ); + } ); + + // Reposition whenever a sidebar's widgets are changed + wp.customize.each( function ( setting ) { + if ( 0 === setting.id.indexOf( 'sidebars_widgets[' ) ) { + setting.bind( function () { + if ( control.container.hasClass( 'expanded' ) ) { + position_widget(); + } + } ); + } + } ); + }, + + /** + * Show/hide the control when clicking on the form title, when clicking + * the close button + */ + _setupControlToggle: function() { + var control = this; + control.container.find( '.widget-top' ).on( 'click', function ( e ) { + e.preventDefault(); + var sidebar_widgets_control = control.getSidebarWidgetsControl(); + if ( sidebar_widgets_control.is_reordering ) { + return; + } + control.toggleForm(); + } ); + + var close_btn = control.container.find( '.widget-control-close' ); + // @todo Hitting Enter on this link does nothing; will be resolved in core with + close_btn.on( 'click', function ( e ) { + e.preventDefault(); + control.collapseForm(); + control.container.find( '.widget-top .widget-action:first' ).focus(); // keyboard accessibility + } ); + }, + + /** + * Update the title of the form if a title field is entered + */ + _setupWidgetTitle: function () { + var control = this; + var update_title = function () { + var title = control.setting().title; + var in_widget_title = control.container.find( '.in-widget-title' ); + if ( title ) { + in_widget_title.text( ': ' + title ); + } else { + in_widget_title.text( '' ); + } + }; + control.setting.bind( update_title ); + update_title(); + }, + + /** + * Set up the widget-reorder-nav + */ + _setupReorderUI: function () { + var control = this; + + /** + * select the provided sidebar list item in the move widget area + * + * @param {jQuery} li + */ + var select_sidebar_item = function ( li ) { + li.siblings( '.selected' ).removeClass( 'selected' ); + li.addClass( 'selected' ); + var is_self_sidebar = ( li.data( 'id' ) === control.params.sidebar_id ); + control.container.find( '.move-widget-btn' ).prop( 'disabled', is_self_sidebar ); + }; + + /** + * Add the widget reordering elements to the widget control + */ + control.container.find( '.widget-title-action' ).after( $( self.tpl.widget_reorder_nav ) ); + var move_widget_area = $( + _.template( self.tpl.move_widget_area, { + sidebars: _( self.registered_sidebars.toArray() ).pluck( 'attributes' ) + } ) + ); + control.container.find( '.widget-top' ).after( move_widget_area ); + + /** + * Update available sidebars when their rendered state changes + */ + var update_available_sidebars = function () { + var sidebar_items = move_widget_area.find( 'li' ); + var self_sidebar_item = sidebar_items.filter( function(){ + return $( this ).data( 'id' ) === control.params.sidebar_id; + } ); + sidebar_items.each( function () { + var li = $( this ); + var sidebar_id = li.data( 'id' ); + var sidebar_model = self.registered_sidebars.get( sidebar_id ); + li.toggle( sidebar_model.get( 'is_rendered' ) ); + if ( li.hasClass( 'selected' ) && ! sidebar_model.get( 'is_rendered' ) ) { + select_sidebar_item( self_sidebar_item ); + } + } ); + }; + update_available_sidebars(); + self.registered_sidebars.on( 'change:is_rendered', update_available_sidebars ); + + /** + * Handle clicks for up/down/move on the reorder nav + */ + var reorder_nav = control.container.find( '.widget-reorder-nav' ); + reorder_nav.find( '.move-widget, .move-widget-down, .move-widget-up' ).on( 'click keypress', function ( event ) { + if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) { + return; + } + $( this ).focus(); + + if ( $( this ).is( '.move-widget' ) ) { + control.toggleWidgetMoveArea(); + } else { + var is_move_down = $( this ).is( '.move-widget-down' ); + var is_move_up = $( this ).is( '.move-widget-up' ); + var i = control.getWidgetSidebarPosition(); + if ( ( is_move_up && i === 0 ) || ( is_move_down && i === control.getSidebarWidgetsControl().setting().length - 1 ) ) { + return; + } + + if ( is_move_up ) { + control.moveUp(); + } else { + control.moveDown(); + } + + $( this ).focus(); // re-focus after the container was moved + } + } ); + + /** + * Handle selecting a sidebar to move to + */ + control.container.find( '.widget-area-select' ).on( 'click keypress', 'li', function ( e ) { + if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) { + return; + } + e.preventDefault(); + select_sidebar_item( $( this ) ); + } ); + + /** + * Move widget to another sidebar + */ + control.container.find( '.move-widget-btn' ).click( function () { + control.getSidebarWidgetsControl().toggleReordering( false ); + + var old_sidebar_id = control.params.sidebar_id; + var new_sidebar_id = control.container.find( '.widget-area-select li.selected' ).data( 'id' ); + var old_sidebar_widgets_setting = customize( 'sidebars_widgets[' + old_sidebar_id + ']' ); + var new_sidebar_widgets_setting = customize( 'sidebars_widgets[' + new_sidebar_id + ']' ); + var old_sidebar_widget_ids = Array.prototype.slice.call( old_sidebar_widgets_setting() ); + var new_sidebar_widget_ids = Array.prototype.slice.call( new_sidebar_widgets_setting() ); + + var i = control.getWidgetSidebarPosition(); + old_sidebar_widget_ids.splice( i, 1 ); + new_sidebar_widget_ids.push( control.params.widget_id ); + + old_sidebar_widgets_setting( old_sidebar_widget_ids ); + new_sidebar_widgets_setting( new_sidebar_widget_ids ); + + control.focus(); + } ); + }, + + /** + * Highlight widgets in preview when interacted with in the customizer + */ + _setupHighlightEffects: function() { + var control = this; + + // Highlight whenever hovering or clicking over the form + control.container.on( 'mouseenter click', function () { + control.highlightPreviewWidget(); + } ); + + // Highlight when the setting is updated + control.setting.bind( function () { + control.scrollPreviewWidgetIntoView(); + control.highlightPreviewWidget(); + } ); + + // Highlight when the widget form is expanded + control.container.on( 'expand', function () { + control.scrollPreviewWidgetIntoView(); + } ); + }, + + /** + * Set up event handlers for widget updating + */ + _setupUpdateUI: function () { + var control = this; + + var widget_content = control.container.find( '.widget-content' ); + + // Configure update button + var save_btn = control.container.find( '.widget-control-save' ); + save_btn.val( self.i18n.save_btn_label ); + save_btn.attr( 'title', self.i18n.save_btn_tooltip ); + save_btn.removeClass( 'button-primary' ).addClass( 'button-secondary' ); + save_btn.on( 'click', function ( e ) { + e.preventDefault(); + control.updateWidget(); + } ); + + var trigger_save = _.debounce( function () { + // @todo For compatibility with other plugins, should we trigger a click event? What about form submit event? + control.updateWidget(); + }, 250 ); + + // Trigger widget form update when hitting Enter within an input + control.container.find( '.widget-content' ).on( 'keydown', 'input', function( e ) { + if ( 13 === e.which ) { // Enter + e.preventDefault(); + control.updateWidget( { ignore_active_element: true } ); + } + } ); + + // Handle widgets that support live previews + widget_content.on( 'change input propertychange', ':input', function ( e ) { + if ( e.type === 'change' || ( this.checkValidity && this.checkValidity() ) ) { + trigger_save(); + } + } ); + + // Remove loading indicators when the setting is saved and the preview updates + control.setting.previewer.channel.bind( 'synced', function () { + control.container.removeClass( 'previewer-loading' ); + } ); + self.previewer.bind( 'widget-updated', function ( updated_widget_id ) { + if ( updated_widget_id === control.params.widget_id ) { + control.container.removeClass( 'previewer-loading' ); + } + } ); + + // Update widget control to indicate whether it is currently rendered (cf. Widget Visibility) + self.previewer.bind( 'rendered-widgets', function ( rendered_widgets ) { + var is_rendered = !! rendered_widgets[control.params.widget_id]; + control.container.toggleClass( 'widget-rendered', is_rendered ); + } ); + }, + + /** + * Set up event handlers for widget removal + */ + _setupRemoveUI: function () { + var control = this; + + // Configure remove button + var remove_btn = control.container.find( 'a.widget-control-remove' ); + // @todo Hitting Enter on this link does nothing; will be resolved in core with + remove_btn.on( 'click', function ( e ) { + e.preventDefault(); + + // Find an adjacent element to add focus to when this widget goes away + var adjacent_focus_target; + if ( control.container.next().is( '.customize-control-widget_form' ) ) { + adjacent_focus_target = control.container.next().find( '.widget-action:first' ); + } else if ( control.container.prev().is( '.customize-control-widget_form' ) ) { + adjacent_focus_target = control.container.prev().find( '.widget-action:first' ); + } else { + adjacent_focus_target = control.container.next( '.customize-control-sidebar_widgets' ).find( '.add-new-widget:first' ); + } + + control.container.slideUp( function() { + var sidebars_widgets_control = self.getSidebarWidgetControlContainingWidget( control.params.widget_id ); + if ( ! sidebars_widgets_control ) { + throw new Error( 'Unable to find sidebars_widgets_control' ); + } + var sidebar_widget_ids = sidebars_widgets_control.setting().slice(); + var i = sidebar_widget_ids.indexOf( control.params.widget_id ); + if ( -1 === i ) { + throw new Error( 'Widget is not in sidebar' ); + } + sidebar_widget_ids.splice( i, 1 ); + sidebars_widgets_control.setting( sidebar_widget_ids ); + adjacent_focus_target.focus(); // keyboard accessibility + } ); + } ); + + var replace_delete_with_remove = function () { + remove_btn.text( self.i18n.remove_btn_label ); // wp_widget_control() outputs the link as "Delete" + remove_btn.attr( 'title', self.i18n.remove_btn_tooltip ); + }; + if ( control.params.is_new ) { + wp.customize.bind( 'saved', replace_delete_with_remove ); + } else { + replace_delete_with_remove(); + } + }, + + /** + * Iterate over supplied inputs and create a signature string for all of them together. + * This string can be used to compare whether or not the form has all of the same fields. + * + * @param {jQuery} inputs + * @returns {string} + * @private + */ + _getInputsSignature: function ( inputs ) { + var inputs_signatures = _( inputs ).map( function ( input ) { + input = $( input ); + var signature_parts; + if ( input.is( 'option' ) ) { + signature_parts = [ input.prop( 'nodeName' ), input.prop( 'value' ) ]; + } else if ( input.is( ':checkbox, :radio' ) ) { + signature_parts = [ input.prop( 'type' ), input.attr( 'id' ), input.attr( 'name' ), input.prop( 'value' ) ]; + } else { + signature_parts = [ input.prop( 'nodeName' ), input.attr( 'id' ), input.attr( 'name' ), input.attr( 'type' ) ]; + } + return signature_parts.join( ',' ); + } ); + return inputs_signatures.join( ';' ); + }, + + /** + * Get the property that represents the state of an input. + * + * @param {jQuery|DOMElement} input + * @returns {string} + * @private + */ + _getInputStatePropertyName: function ( input ) { + input = $( input ); + if ( input.is( ':radio, :checkbox' ) ) { + return 'checked'; + } else if ( input.is( 'option' ) ) { + return 'selected'; + } else { + return 'value'; + } + }, + + /*********************************************************************** + * Begin public API methods + **********************************************************************/ + + /** + * @return {wp.customize.controlConstructor.sidebar_widgets[]} + */ + getSidebarWidgetsControl: function () { + var control = this; + var setting_id = 'sidebars_widgets[' + control.params.sidebar_id + ']'; + var sidebar_widgets_control = customize.control( setting_id ); + if ( ! sidebar_widgets_control ) { + throw new Error( 'Unable to locate sidebar_widgets control for ' + control.params.sidebar_id ); + } + return sidebar_widgets_control; + }, + + /** + * Submit the widget form via Ajax and get back the updated instance, + * along with the new widget control form to render. + * + * @param {object} [args] + * @param {Object|null} [args.instance=null] When the model changes, the instance is sent here; otherwise, the inputs from the form are used + * @param {Function|null} [args.complete=null] Function which is called when the request finishes. Context is bound to the control. First argument is any error. Following arguments are for success. + * @param {Boolean} [args.ignore_active_element=false] Whether or not updating a field will be deferred if focus is still on the element. + */ + updateWidget: function ( args ) { + var control = this; + args = $.extend( { + instance: null, + complete: null, + ignore_active_element: false + }, args ); + var instance_override = args.instance; + var complete_callback = args.complete; + + control._update_count += 1; + var update_number = control._update_count; + + var widget_content = control.container.find( '.widget-content' ); + + var element_id_to_refocus = null; + var active_input_selection_start = null; + var active_input_selection_end = null; + // @todo Support more selectors than IDs? + if ( $.contains( control.container[0], document.activeElement ) && $( document.activeElement ).is( '[id]' ) ) { + element_id_to_refocus = $( document.activeElement ).prop( 'id' ); + // @todo IE8 support: http://stackoverflow.com/a/4207763/93579 + try { + active_input_selection_start = document.activeElement.selectionStart; + active_input_selection_end = document.activeElement.selectionEnd; + } + catch( e ) {} // catch InvalidStateError in case of checkboxes + } + + control.container.addClass( 'widget-form-loading' ); + control.container.addClass( 'previewer-loading' ); + + var params = {}; + params.action = self.update_widget_ajax_action; + params[self.update_widget_nonce_post_key] = self.update_widget_nonce_value; + + var data = $.param( params ); + var inputs = widget_content.find( ':input, option' ); + + // Store the value we're submitting in data so that when the response comes back, + // we know if it got sanitized; if there is no difference in the sanitized value, + // then we do not need to touch the UI and mess up the user's ongoing editing. + inputs.each( function () { + var input = $( this ); + var property = control._getInputStatePropertyName( this ); + input.data( 'state' + update_number, input.prop( property ) ); + } ); + + if ( instance_override ) { + data += '&' + $.param( { 'sanitized_widget_setting': JSON.stringify( instance_override ) } ); + } else { + data += '&' + inputs.serialize(); + } + data += '&' + widget_content.find( '~ :input' ).serialize(); + + console.log( wp.ajax.settings.url, data ); + var jqxhr = $.post( wp.ajax.settings.url, data, function ( r ) { + if ( r.success ) { + var sanitized_form = $( '
    ' + r.data.form + '
    ' ); + control.hook( 'formUpdate', sanitized_form ); + + var sanitized_inputs = sanitized_form.find( ':input, option' ); + var has_same_inputs_in_response = control._getInputsSignature( inputs ) === control._getInputsSignature( sanitized_inputs ); + + if ( has_same_inputs_in_response ) { + inputs.each( function ( i ) { + var input = $( this ); + var sanitized_input = $( sanitized_inputs[i] ); + var property = control._getInputStatePropertyName( this ); + var state = input.data( 'state' + update_number ); + var sanitized_state = sanitized_input.prop( property ); + input.data( 'sanitized', sanitized_state ); + + if ( state !== sanitized_state ) { + + // Only update now if not currently focused on it, + // so that we don't cause the cursor + // it will be updated upon the change event + if ( args.ignore_active_element || ! input.is( document.activeElement ) ) { + input.prop( property, sanitized_state ); + } + control.hook( 'unsanitaryField', input, sanitized_state, state ); + + } else { + control.hook( 'sanitaryField', input, state ); + } + } ); + control.hook( 'formUpdated', sanitized_form ); + } else { + widget_content.html( sanitized_form.html() ); + if ( element_id_to_refocus ) { + // not using jQuery selector so we don't have to worry about escaping IDs with brackets and other characters + $( document.getElementById( element_id_to_refocus ) ) + .prop( { + selectionStart: active_input_selection_start, + selectionEnd: active_input_selection_end + } ) + .focus(); + } + control.hook( 'formRefreshed' ); + } + + /** + * If the old instance is identical to the new one, there is nothing new + * needing to be rendered, and so we can preempt the event for the + * preview finishing loading. + */ + var is_instance_identical = _( control.setting() ).isEqual( r.data.instance ); + if ( is_instance_identical ) { + control.container.removeClass( 'previewer-loading' ); + } else { + control.is_widget_updating = true; // suppress triggering another updateWidget + control.setting( r.data.instance ); + control.is_widget_updating = false; + } + + if ( complete_callback ) { + complete_callback.call( control, null, { no_change: is_instance_identical, ajax_finished: true } ); + } + } else { + console.log( r ); + var message = 'FAIL'; + if ( r.data && r.data.message ) { + message = r.data.message; + } + if ( complete_callback ) { + complete_callback.call( control, message ); + } else { + throw new Error( message ); + } + } + } ); + jqxhr.fail( function ( jqXHR, textStatus ) { + if ( complete_callback ) { + complete_callback.call( control, textStatus ); + } else { + throw new Error( textStatus ); + } + } ); + jqxhr.always( function () { + control.container.removeClass( 'widget-form-loading' ); + inputs.each( function () { + $( this ).removeData( 'state' + update_number ); + } ); + } ); + }, + + /** + * Expand the accordion section containing a control + * @todo it would be nice if accordion had a proper API instead of having to trigger UI events on its elements + */ + expandControlSection: function () { + var section = this.container.closest( '.accordion-section' ); + if ( ! section.hasClass( 'open' ) ) { + section.find( '.accordion-section-title:first' ).trigger( 'click' ); + } + }, + + /** + * Expand the widget form control + */ + expandForm: function () { + this.toggleForm( true ); + }, + + /** + * Collapse the widget form control + */ + collapseForm: function () { + this.toggleForm( false ); + }, + + /** + * Expand or collapse the widget control + * + * @param {boolean|undefined} [do_expand] If not supplied, will be inverse of current visibility + */ + toggleForm: function ( do_expand ) { + var control = this; + var widget = control.container.find( 'div.widget:first' ); + var inside = widget.find( '.widget-inside:first' ); + if ( typeof do_expand === 'undefined' ) { + do_expand = ! inside.is( ':visible' ); + } + + // Already expanded or collapsed, so noop + if ( inside.is( ':visible' ) === do_expand ) { + return; + } + + var complete; + if ( do_expand ) { + // Close all other widget controls before expanding this one + wp.customize.control.each( function ( other_control ) { + if ( control.params.type === other_control.params.type && control !== other_control ) { + other_control.collapseForm(); + } + } ); + + control.container.trigger( 'expand' ); + control.container.addClass( 'expanding' ); + complete = function () { + control.container.removeClass( 'expanding' ); + control.container.addClass( 'expanded' ); + control.container.trigger( 'expanded' ); + }; + if ( control.params.is_wide ) { + inside.animate( { width: 'show' }, 'fast', complete ); + } else { + inside.slideDown( 'fast', complete ); + } + } else { + control.container.trigger( 'collapse' ); + control.container.addClass( 'collapsing' ); + complete = function () { + control.container.removeClass( 'collapsing' ); + control.container.removeClass( 'expanded' ); + control.container.trigger( 'collapsed' ); + }; + if ( control.params.is_wide ) { + inside.animate( { width: 'hide' }, 'fast', complete ); + } else { + inside.slideUp( 'fast', function() { + widget.css( { width:'', margin:'' } ); + complete(); + } ); + } + } + }, + + /** + * Expand the containing sidebar section, expand the form, and focus on + * the first input in the control + */ + focus: function () { + var control = this; + control.expandControlSection(); + control.expandForm(); + control.container.find( ':focusable:first' ).focus().trigger( 'click' ); + }, + + /** + * Get the position (index) of the widget in the containing sidebar + * + * @throws Error + * @returns {Number} + */ + getWidgetSidebarPosition: function () { + var control = this; + var sidebar_widget_ids = control.getSidebarWidgetsControl().setting(); + var position = sidebar_widget_ids.indexOf( control.params.widget_id ); + if ( position === -1 ) { + throw new Error( 'Widget was unexpectedly not present in the sidebar.' ); + } + return position; + }, + + /** + * Move widget up one in the sidebar + */ + moveUp: function () { + this._moveWidgetByOne( -1 ); + }, + + /** + * Move widget up one in the sidebar + */ + moveDown: function () { + this._moveWidgetByOne( 1 ); + }, + + /** + * @private + * + * @param {Number} offset 1|-1 + */ + _moveWidgetByOne: function ( offset ) { + var control = this; + var i = control.getWidgetSidebarPosition(); + + var sidebar_widgets_setting = control.getSidebarWidgetsControl().setting; + var sidebar_widget_ids = Array.prototype.slice.call( sidebar_widgets_setting() ); // clone + var adjacent_widget_id = sidebar_widget_ids[i + offset]; + sidebar_widget_ids[i + offset] = control.params.widget_id; + sidebar_widget_ids[i] = adjacent_widget_id; + + sidebar_widgets_setting( sidebar_widget_ids ); + }, + + /** + * Toggle visibility of the widget move area + * + * @param {Boolean} [toggle] + */ + toggleWidgetMoveArea: function ( toggle ) { + var control = this; + var move_widget_area = control.container.find( '.move-widget-area' ); + if ( typeof toggle === 'undefined' ) { + toggle = ! move_widget_area.hasClass( 'active' ); + } + if ( toggle ) { + // reset the selected sidebar + move_widget_area.find( '.selected' ).removeClass( 'selected' ); + move_widget_area.find( 'li' ).filter( function () { + return $( this ).data( 'id' ) === control.params.sidebar_id; + } ).addClass( 'selected' ); + control.container.find( '.move-widget-btn' ).prop( 'disabled', true ); + } + move_widget_area.toggleClass( 'active', toggle ); + }, + + /** + * Inverse of WidgetCustomizer.getControlInstanceForWidget + * @return {jQuery} + */ + getPreviewWidgetElement: function () { + var control = this; + var widget_customizer_preview = self.getPreviewWindow().WidgetCustomizerPreview; + return widget_customizer_preview.getSidebarWidgetElement( control.params.sidebar_id, control.params.widget_id ); + }, + + /** + * Inside of the customizer preview, scroll the widget into view + */ + scrollPreviewWidgetIntoView: function () { + // @todo scrollIntoView() provides a robust but very poor experience. Animation is needed. See https://github.com/x-team/wp-widget-customizer/issues/16 + }, + + /** + * Highlight the widget control and section + */ + highlightSectionAndControl: function() { + var control = this; + var target_element; + if ( control.container.is( ':hidden' ) ) { + target_element = control.container.closest( '.control-section' ); + } else { + target_element = control.container; + } + + $( '.widget-customizer-highlighted' ).removeClass( 'widget-customizer-highlighted' ); + target_element.addClass( 'widget-customizer-highlighted' ); + setTimeout( function () { + target_element.removeClass( 'widget-customizer-highlighted' ); + }, 500 ); + }, + + /** + * Add the widget-customizer-highlighted-widget class to the widget for 500ms + */ + highlightPreviewWidget: function () { + var control = this; + var widget_el = control.getPreviewWidgetElement(); + var root_el = widget_el.closest( 'html' ); + root_el.find( '.widget-customizer-highlighted-widget' ).removeClass( 'widget-customizer-highlighted-widget' ); + widget_el.addClass( 'widget-customizer-highlighted-widget' ); + setTimeout( function () { + widget_el.removeClass( 'widget-customizer-highlighted-widget' ); + }, 500 ); + } + + } ); + + /** + * Capture the instance of the Previewer since it is private + */ + var OldPreviewer = wp.customize.Previewer; + wp.customize.Previewer = OldPreviewer.extend( { + initialize: function( params, options ) { + self.previewer = this; + OldPreviewer.prototype.initialize.call( this, params, options ); + this.bind( 'refresh', this.refresh ); + } + } ); + + /** + * Given a widget control, find the sidebar widgets control that contains it. + * @param {string} widget_id + * @return {object|null} + */ + self.getSidebarWidgetControlContainingWidget = function ( widget_id ) { + var found_control = null; + // @todo this can use widget_id_to_setting_id(), then pass into wp.customize.control( x ).getSidebarWidgetsControl() + wp.customize.control.each( function ( control ) { + if ( control.params.type === 'sidebar_widgets' && -1 !== control.setting().indexOf( widget_id ) ) { + found_control = control; + } + } ); + return found_control; + }; + + /** + * Given a widget_id for a widget appearing in the preview, get the widget form control associated with it + * @param {string} widget_id + * @return {object|null} + */ + self.getWidgetFormControlForWidget = function ( widget_id ) { + var found_control = null; + // @todo We can just use widget_id_to_setting_id() here + wp.customize.control.each( function ( control ) { + if ( control.params.type === 'widget_form' && control.params.widget_id === widget_id ) { + found_control = control; + } + } ); + return found_control; + }; + + /** + * @returns {Window} + */ + self.getPreviewWindow = function (){ + return $( '#customize-preview' ).find( 'iframe' ).prop( 'contentWindow' ); + }; + + /** + * Available Widgets Panel + */ + self.availableWidgetsPanel = { + active_sidebar_widgets_control: null, + selected_widget_tpl: null, + container: null, + filter_input: null, + + /** + * Set up event listeners + */ + setup: function () { + var panel = this; + panel.container = $( '#available-widgets' ); + panel.filter_input = $( '#available-widgets-filter' ).find( 'input' ); + + var update_available_widgets_list = function () { + self.available_widgets.each( function ( widget ) { + var widget_tpl = $( '#widget-tpl-' + widget.id ); + widget_tpl.toggle( ! widget.get( 'is_disabled' ) ); + if ( widget.get( 'is_disabled' ) && widget_tpl.is( panel.selected_widget_tpl ) ) { + panel.selected_widget_tpl = null; + } + } ); + }; + + self.available_widgets.on( 'change', update_available_widgets_list ); + update_available_widgets_list(); + + // If the available widgets panel is open and the customize controls are + // interacted with (i.e. available widgets panel is blurred) then close the + // available widgets panel. + $( '#customize-controls' ).on( 'click keydown', function ( e ) { + var is_add_new_widget_btn = $( e.target ).is( '.add-new-widget, .add-new-widget *' ); + if ( $( 'body' ).hasClass( 'adding-widget' ) && ! is_add_new_widget_btn ) { + panel.close(); + } + } ); + + // Close the panel if the URL in the preview changes + self.previewer.bind( 'url', function () { + panel.close(); + } ); + + // Submit a selection when clicked or keypressed + panel.container.find( '.widget-tpl' ).on( 'click keypress', function( event ) { + + // Only proceed with keypress if it is Enter or Spacebar + if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) { + return; + } + + panel.submit( this ); + } ); + + panel.container.liveFilter( + '#available-widgets-filter input', + '.widget-tpl', + { + filterChildSelector: '.widget-title h4', + after: function () { + var filter_val = panel.filter_input.val(); + + // Remove a widget from being selected if it is no longer visible + if ( panel.selected_widget_tpl && ! panel.selected_widget_tpl.is( ':visible' ) ) { + panel.selected_widget_tpl.removeClass( 'selected' ); + panel.selected_widget_tpl = null; + } + + // If a widget was selected but the filter value has been cleared out, clear selection + if ( panel.selected_widget_tpl && ! filter_val ) { + panel.selected_widget_tpl.removeClass( 'selected' ); + panel.selected_widget_tpl = null; + } + + // If a filter has been entered and a widget hasn't been selected, select the first one shown + if ( ! panel.selected_widget_tpl && filter_val ) { + var first_visible_widget = panel.container.find( '> .widget-tpl:visible:first' ); + if ( first_visible_widget.length ) { + panel.select( first_visible_widget ); + } + } + + } + } + ); + + // Select a widget when it is focused on + panel.container.find( ' > .widget-tpl' ).on( 'focus', function () { + panel.select( this ); + } ); + + panel.container.on( 'keydown', function ( event ) { + var is_enter = ( event.which === 13 ); + var is_esc = ( event.which === 27 ); + var is_down = ( event.which === 40 ); + var is_up = ( event.which === 38 ); + var selected_widget_tpl = null; + var first_visible_widget = panel.container.find( '> .widget-tpl:visible:first' ); + var last_visible_widget = panel.container.find( '> .widget-tpl:visible:last' ); + var is_input_focused = $( event.target ).is( panel.filter_input ); + + if ( is_down || is_up ) { + if ( is_down ) { + if ( is_input_focused ) { + selected_widget_tpl = first_visible_widget; + } else if ( panel.selected_widget_tpl && panel.selected_widget_tpl.nextAll( '.widget-tpl:visible' ).length !== 0 ) { + selected_widget_tpl = panel.selected_widget_tpl.nextAll( '.widget-tpl:visible:first' ); + } + } else if ( is_up ) { + if ( is_input_focused ) { + selected_widget_tpl = last_visible_widget; + } else if ( panel.selected_widget_tpl && panel.selected_widget_tpl.prevAll( '.widget-tpl:visible' ).length !== 0 ) { + selected_widget_tpl = panel.selected_widget_tpl.prevAll( '.widget-tpl:visible:first' ); + } + } + panel.select( selected_widget_tpl ); + if ( selected_widget_tpl ) { + selected_widget_tpl.focus(); + } else { + panel.filter_input.focus(); + } + return; + } + + // If enter pressed but nothing entered, don't do anything + if ( is_enter && ! panel.filter_input.val() ) { + return; + } + + if ( is_enter ) { + panel.submit(); + } else if ( is_esc ) { + panel.close( { return_focus: true } ); + } + } ); + }, + + /** + * @param widget_tpl + */ + select: function ( widget_tpl ) { + var panel = this; + panel.selected_widget_tpl = $( widget_tpl ); + panel.selected_widget_tpl.siblings( '.widget-tpl' ).removeClass( 'selected' ); + panel.selected_widget_tpl.addClass( 'selected' ); + }, + + submit: function ( widget_tpl ) { + var panel = this; + if ( ! widget_tpl ) { + widget_tpl = panel.selected_widget_tpl; + } + if ( ! widget_tpl || ! panel.active_sidebar_widgets_control ) { + return; + } + panel.select( widget_tpl ); + + var widget_id = $( panel.selected_widget_tpl ).data( 'widget-id' ); + var widget = self.available_widgets.findWhere( {id: widget_id} ); + if ( ! widget ) { + throw new Error( 'Widget unexpectedly not found.' ); + } + panel.active_sidebar_widgets_control.addWidget( widget.get( 'id_base' ) ); + panel.close(); + }, + + /** + * @param sidebars_widgets_control + */ + open: function ( sidebars_widgets_control ) { + var panel = this; + panel.active_sidebar_widgets_control = sidebars_widgets_control; + + // Wide widget controls appear over the preview, and so they need to be collapsed when the panel opens + _( sidebars_widgets_control.getWidgetFormControls() ).each( function ( control ) { + if ( control.params.is_wide ) { + control.collapseForm(); + } + } ); + + $( 'body' ).addClass( 'adding-widget' ); + panel.container.find( '.widget-tpl' ).removeClass( 'selected' ); + panel.filter_input.focus(); + }, + + /** + * Hide the panel + */ + close: function ( options ) { + var panel = this; + options = options || {}; + if ( options.return_focus && panel.active_sidebar_widgets_control ) { + panel.active_sidebar_widgets_control.container.find( '.add-new-widget' ).focus(); + } + panel.active_sidebar_widgets_control = null; + panel.selected_widget_tpl = null; + $( 'body' ).removeClass( 'adding-widget' ); + panel.filter_input.val( '' ); + } + }; + + /** + * @param {String} widget_id + * @returns {Object} + */ + function parse_widget_id( widget_id ) { + var parsed = { + number: null, + id_base: null + }; + var matches = widget_id.match( /^(.+)-(\d+)$/ ); + if ( matches ) { + parsed.id_base = matches[1]; + parsed.number = parseInt( matches[2], 10 ); + } else { + // likely an old single widget + parsed.id_base = widget_id; + } + return parsed; + } + + /** + * @param {String} widget_id + * @returns {String} setting_id + */ + function widget_id_to_setting_id( widget_id ) { + var parsed = parse_widget_id( widget_id ); + var setting_id = 'widget_' + parsed.id_base; + if ( parsed.number ) { + setting_id += '[' + parsed.number + ']'; + } + return setting_id; + } + + return self; +}( jQuery )); + +/* @todo remove this dependency */ +/* + * jQuery.liveFilter + * + * Copyright (c) 2009 Mike Merritt + * + * Forked by Lim Chee Aun (cheeaun.com) + * + */ + +(function($){ + $.fn.liveFilter = function(inputEl, filterEl, options){ + var defaults = { + filterChildSelector: null, + filter: function(el, val){ + return $(el).text().toUpperCase().indexOf(val.toUpperCase()) >= 0; + }, + before: function(){}, + after: function(){} + }; + options = $.extend(defaults, options); + + var el = $(this).find(filterEl); + if (options.filterChildSelector) { + el = el.find(options.filterChildSelector); + } + + var filter = options.filter; + $(inputEl).keyup(function(){ + var val = $(this).val(); + var contains = el.filter(function(){ + return filter(this, val); + }); + var containsNot = el.not(contains); + if (options.filterChildSelector){ + contains = contains.parents(filterEl); + containsNot = containsNot.parents(filterEl).hide(); + } + + options.before.call(this, contains, containsNot); + + contains.show(); + containsNot.hide(); + + if (val === '') { + contains.show(); + containsNot.show(); + } + + options.after.call(this, contains, containsNot); + }); + }; +})(jQuery); diff --git a/wp-admin/js/customize-widgets.min.js b/wp-admin/js/customize-widgets.min.js new file mode 100644 index 0000000000..ac73463aa3 --- /dev/null +++ b/wp-admin/js/customize-widgets.min.js @@ -0,0 +1 @@ +var WidgetCustomizer=function(a){"use strict";function b(a){var b={number:null,id_base:null},c=a.match(/^(.+)-(\d+)$/);return c?(b.id_base=c[1],b.number=parseInt(c[2],10)):b.id_base=a,b}function c(a){var c=b(a),d="widget_"+c.id_base;return c.number&&(d+="["+c.number+"]"),d}var d=wp.customize,e={update_widget_ajax_action:null,update_widget_nonce_value:null,update_widget_nonce_post_key:null,i18n:{save_btn_label:"",save_btn_tooltip:"",remove_btn_label:"",remove_btn_tooltip:""},available_widgets:[],registered_widgets:[],active_sidebar_control:null,previewer:null,saved_widget_ids:{},registered_sidebars:[],tpl:{move_widget_area:"",widget_reorder_nav:""}};a.extend(e,WidgetCustomizer_exports),"undefined"==typeof window.ajaxurl&&(window.ajaxurl=wp.ajax.settings.url),a("#customize-theme-controls").closest("div:not([id])").attr("id","widgets-right");var f=e.Widget=Backbone.Model.extend({id:null,temp_id:null,classname:null,control_tpl:null,description:null,is_disabled:null,is_multi:null,multi_number:null,name:null,id_base:null,transport:"refresh",params:[],width:null,height:null}),g=e.WidgetCollection=Backbone.Collection.extend({model:f});e.available_widgets=new g(e.available_widgets);var h=e.Sidebar=Backbone.Model.extend({after_title:null,after_widget:null,before_title:null,before_widget:null,"class":null,description:null,id:null,name:null,is_rendered:!1}),i=e.SidebarCollection=Backbone.Collection.extend({model:h});e.registered_sidebars=new i(e.registered_sidebars),e.init=function(){this.showFirstSidebarIfRequested(),this.availableWidgetsPanel.setup()},wp.customize.bind("ready",function(){e.init()}),e.showFirstSidebarIfRequested=function(){if(/widget-customizer=open/.test(location.search)){var b=function(){e.registered_sidebars.off("change:is_rendered",b);var c=e.registered_sidebars.find(function(a){return a.get("is_rendered")});if(c){var d=a("#accordion-section-sidebar-widgets-"+c.get("id"));d.hasClass("open")||d.find(".accordion-section-title").trigger("click"),d[0].scrollIntoView()}};b=_.debounce(b,100),e.registered_sidebars.on("change:is_rendered",b)}},d.controlConstructor.sidebar_widgets=d.Control.extend({ready:function(){var a=this;a.control_section=a.container.closest(".control-section"),a.section_content=a.container.closest(".accordion-section-content"),a._setupModel(),a._setupSortable(),a._setupAddition(),a._applyCardinalOrderClassNames()},_setupModel:function(){var c=this,d=e.registered_sidebars.get(c.params.sidebar_id);c.setting.bind(function(d,f){var g=_(f).difference(d);d=_(d).filter(function(a){var c=b(a);return!!e.available_widgets.findWhere({id_base:c.id_base})});var h=_(d).map(function(a){var b=e.getWidgetFormControlForWidget(a);return b||(b=c.addWidget(a)),b});h.sort(function(a,b){var c=d.indexOf(a.params.widget_id),e=d.indexOf(b.params.widget_id);return c===e?0:e>c?-1:1});var i=c.section_content.find(".customize-control-sidebar_widgets"),j=_(h).map(function(a){return a.container[0]});i.before(j),c._applyCardinalOrderClassNames(),_(h).each(function(a){a.params.sidebar_id=c.params.sidebar_id}),_(g).each(function(d){setTimeout(function(){var f=!1;if(wp.customize.each(function(a){if(a.id!==c.setting.id&&0===a.id.indexOf("sidebars_widgets[")&&"sidebars_widgets[wp_inactive_widgets]"!==a.id){var b=a(),e=b.indexOf(d);-1!==e&&(f=!0)}}),!f){var g=e.getWidgetFormControlForWidget(d),h=g&&a.contains(document,g.container[0])&&!a.contains(c.section_content[0],g.container[0]);if(g&&!h&&(wp.customize.control.remove(g.id),g.container.remove()),e.saved_widget_ids[d]){var i=wp.customize.value("sidebars_widgets[wp_inactive_widgets]")().slice();i.push(d),wp.customize.value("sidebars_widgets[wp_inactive_widgets]")(_(i).unique())}var j=b(d).id_base,k=e.available_widgets.findWhere({id_base:j});k&&!k.get("is_multi")&&k.set("is_disabled",!1)}})})}),e.previewer.bind("rendered-sidebars",function(a){var b=!!a[c.params.sidebar_id];d.set("is_rendered",b)}),d.on("change:is_rendered",function(){var b="#accordion-section-sidebar-widgets-"+this.get("id"),c=a(b);this.get("is_rendered")?c.stop().slideDown(function(){a(this).css("height","auto")}):(c.hasClass("open")&&c.find(".accordion-section-title").trigger("click"),c.stop().slideUp())})},_setupSortable:function(){var b=this;b.is_reordering=!1,b.section_content.sortable({items:"> .customize-control-widget_form",handle:".widget-top",axis:"y",connectWith:".accordion-section-content:has(.customize-control-sidebar_widgets)",update:function(){var c=b.section_content.sortable("toArray"),d=a.map(c,function(b){return a("#"+b).find(":input[name=widget-id]").val()});b.setting(d)}}),b.control_section.find(".accordion-section-title").droppable({accept:".customize-control-widget_form",over:function(){b.control_section.hasClass("open")||(b.control_section.addClass("open"),b.section_content.toggle(!1).slideToggle(150,function(){b.section_content.sortable("refreshPositions")}))}}),b.container.find(".reorder-toggle").on("click keydown",function(a){("keydown"!==a.type||13===a.which||32===a.which)&&b.toggleReordering(!b.is_reordering)})},_setupAddition:function(){var b=this;b.container.find(".add-new-widget").on("click keydown",function(c){("keydown"!==c.type||13===c.which||32===c.which)&&(b.section_content.hasClass("reordering")||(a("body").hasClass("adding-widget")?e.availableWidgetsPanel.close():e.availableWidgetsPanel.open(b)))})},_applyCardinalOrderClassNames:function(){var a=this;a.section_content.find(".customize-control-widget_form").removeClass("first-widget").removeClass("last-widget").find(".move-widget-down, .move-widget-up").prop("tabIndex",0),a.section_content.find(".customize-control-widget_form:first").addClass("first-widget").find(".move-widget-up").prop("tabIndex",-1),a.section_content.find(".customize-control-widget_form:last").addClass("last-widget").find(".move-widget-down").prop("tabIndex",-1)},toggleReordering:function(a){var b=this;a=Boolean(a),a!==b.section_content.hasClass("reordering")&&(b.is_reordering=a,b.section_content.toggleClass("reordering",a),a&&_(b.getWidgetFormControls()).each(function(a){a.collapseForm()}))},getWidgetFormControls:function(){var a=this,b=_(a.setting()).map(function(a){var b=c(a),e=d.control(b);if(!e)throw new Error("Unable to find widget_form control for "+a);return e});return b},addWidget:function(c){var d=this,f=b(c),g=f.number,h=f.id_base,i=e.available_widgets.findWhere({id_base:h});if(!i)throw new Error("Widget unexpectedly not found.");if(g&&!i.get("is_multi"))throw new Error("Did not expect a widget number to be supplied for a non-multi widget");i.get("is_multi")&&!g&&(i.set("multi_number",i.get("multi_number")+1),g=i.get("multi_number"));var j=a("#widget-tpl-"+i.get("id")).html();i.get("is_multi")?j=j.replace(/<[^<>]+>/g,function(a){return a.replace(/__i__|%i%/g,g)}):i.set("is_disabled",!0);var k="widget_form",l=a("
  • ");l.addClass("customize-control"),l.addClass("customize-control-"+k),l.append(a(j)),l.find("> .widget-icon").remove(),i.get("is_multi")&&(l.find('input[name="widget_number"]').val(g),l.find('input[name="multi_number"]').val(g)),c=l.find('[name="widget-id"]').val(),l.hide();var m="widget_"+i.get("id_base");i.get("is_multi")&&(m+="["+g+"]"),l.attr("id","customize-control-"+m.replace(/\]/g,"").replace(/\[/g,"-")),d.container.after(l);var n=wp.customize.has(m);if(!n){var o={transport:"refresh",previewer:d.setting.previewer};wp.customize.create(m,m,{},o)}var p=wp.customize.controlConstructor[k],q=new p(m,{params:{settings:{"default":m},sidebar_id:d.params.sidebar_id,widget_id:c,widget_id_base:i.get("id_base"),type:k,is_new:!n,width:i.get("width"),height:i.get("height"),is_wide:i.get("is_wide")},previewer:d.setting.previewer});wp.customize.control.add(m,q),wp.customize.each(function(a){if(a.id!==d.setting.id&&0===a.id.indexOf("sidebars_widgets[")){var b=a().slice(),e=b.indexOf(c);-1!==e&&(b.splice(e),a(b))}});var r=d.setting().slice();return-1===r.indexOf(c)&&(r.push(c),d.setting(r)),l.slideDown(function(){n?(q.expandForm(),q.updateWidget({instance:q.setting(),complete:function(a){if(a)throw a;q.focus()}})):q.focus()}),q}}),d.controlConstructor.widget_form=d.Control.extend({ready:function(){var a=this;a._setupModel(),a._setupWideWidget(),a._setupControlToggle(),a._setupWidgetTitle(),a._setupReorderUI(),a._setupHighlightEffects(),a._setupUpdateUI(),a._setupRemoveUI(),a.hook("init")},hooks:{_default:{},rss:{formUpdated:function(a){var b=this,c=b.container.find(".widget-error:first"),d=a.find(".widget-error:first");c.length&&d.length?c.replaceWith(d):c.length?c.remove():d.length&&b.container.find(".widget-content").prepend(d)}}},hook:function(a){var b,c=Array.prototype.slice.call(arguments,1);this.hooks[this.params.widget_id_base]&&this.hooks[this.params.widget_id_base][a]?b=this.hooks[this.params.widget_id_base][a]:this.hooks._default[a]&&(b=this.hooks._default[a]),b&&b.apply(this,c)},_setupModel:function(){var a=this,b=function(){e.saved_widget_ids[a.params.widget_id]=!0};wp.customize.bind("ready",b),wp.customize.bind("saved",b),a._update_count=0,a.is_widget_updating=!1,a.setting.bind(function(b,c){_(c).isEqual(b)||a.is_widget_updating||a.updateWidget({instance:b})})},_setupWideWidget:function(){var b=this;if(b.params.is_wide){var c=b.container.find(".widget-inside"),d=a(".wp-full-overlay-sidebar-content:first");b.container.addClass("wide-widget-control"),b.container.find(".widget-content:first").css({"min-width":b.params.width,"min-height":b.params.height});var e=function(){var d=b.container.offset().top,e=c.outerHeight(),f=Math.max(d,0),g=a(window).height()-e;f=Math.min(f,g),c.css("top",f)},f=a("#customize-theme-controls");b.container.on("expand",function(){d.on("scroll",e),a(window).on("resize",e),f.on("expanded collapsed",e),e()}),b.container.on("collapsed",function(){d.off("scroll",e),f.off("expanded collapsed",e),a(window).off("resize",e)}),wp.customize.each(function(a){0===a.id.indexOf("sidebars_widgets[")&&a.bind(function(){b.container.hasClass("expanded")&&e()})})}},_setupControlToggle:function(){var a=this;a.container.find(".widget-top").on("click",function(b){b.preventDefault();var c=a.getSidebarWidgetsControl();c.is_reordering||a.toggleForm()});var b=a.container.find(".widget-control-close");b.on("click",function(b){b.preventDefault(),a.collapseForm(),a.container.find(".widget-top .widget-action:first").focus()})},_setupWidgetTitle:function(){var a=this,b=function(){var b=a.setting().title,c=a.container.find(".in-widget-title");c.text(b?": "+b:"")};a.setting.bind(b),b()},_setupReorderUI:function(){var b=this,c=function(a){a.siblings(".selected").removeClass("selected"),a.addClass("selected");var c=a.data("id")===b.params.sidebar_id;b.container.find(".move-widget-btn").prop("disabled",c)};b.container.find(".widget-title-action").after(a(e.tpl.widget_reorder_nav));var f=a(_.template(e.tpl.move_widget_area,{sidebars:_(e.registered_sidebars.toArray()).pluck("attributes")}));b.container.find(".widget-top").after(f);var g=function(){var d=f.find("li"),g=d.filter(function(){return a(this).data("id")===b.params.sidebar_id});d.each(function(){var b=a(this),d=b.data("id"),f=e.registered_sidebars.get(d);b.toggle(f.get("is_rendered")),b.hasClass("selected")&&!f.get("is_rendered")&&c(g)})};g(),e.registered_sidebars.on("change:is_rendered",g);var h=b.container.find(".widget-reorder-nav");h.find(".move-widget, .move-widget-down, .move-widget-up").on("click keypress",function(c){if("keypress"!==c.type||13===c.which||32===c.which)if(a(this).focus(),a(this).is(".move-widget"))b.toggleWidgetMoveArea();else{var d=a(this).is(".move-widget-down"),e=a(this).is(".move-widget-up"),f=b.getWidgetSidebarPosition();if(e&&0===f||d&&f===b.getSidebarWidgetsControl().setting().length-1)return;e?b.moveUp():b.moveDown(),a(this).focus()}}),b.container.find(".widget-area-select").on("click keypress","li",function(b){("keypress"!==event.type||13===event.which||32===event.which)&&(b.preventDefault(),c(a(this)))}),b.container.find(".move-widget-btn").click(function(){b.getSidebarWidgetsControl().toggleReordering(!1);var a=b.params.sidebar_id,c=b.container.find(".widget-area-select li.selected").data("id"),e=d("sidebars_widgets["+a+"]"),f=d("sidebars_widgets["+c+"]"),g=Array.prototype.slice.call(e()),h=Array.prototype.slice.call(f()),i=b.getWidgetSidebarPosition();g.splice(i,1),h.push(b.params.widget_id),e(g),f(h),b.focus()})},_setupHighlightEffects:function(){var a=this;a.container.on("mouseenter click",function(){a.highlightPreviewWidget()}),a.setting.bind(function(){a.scrollPreviewWidgetIntoView(),a.highlightPreviewWidget()}),a.container.on("expand",function(){a.scrollPreviewWidgetIntoView()})},_setupUpdateUI:function(){var a=this,b=a.container.find(".widget-content"),c=a.container.find(".widget-control-save");c.val(e.i18n.save_btn_label),c.attr("title",e.i18n.save_btn_tooltip),c.removeClass("button-primary").addClass("button-secondary"),c.on("click",function(b){b.preventDefault(),a.updateWidget()});var d=_.debounce(function(){a.updateWidget()},250);a.container.find(".widget-content").on("keydown","input",function(b){13===b.which&&(b.preventDefault(),a.updateWidget({ignore_active_element:!0}))}),b.on("change input propertychange",":input",function(a){("change"===a.type||this.checkValidity&&this.checkValidity())&&d()}),a.setting.previewer.channel.bind("synced",function(){a.container.removeClass("previewer-loading")}),e.previewer.bind("widget-updated",function(b){b===a.params.widget_id&&a.container.removeClass("previewer-loading")}),e.previewer.bind("rendered-widgets",function(b){var c=!!b[a.params.widget_id];a.container.toggleClass("widget-rendered",c)})},_setupRemoveUI:function(){var a=this,b=a.container.find("a.widget-control-remove");b.on("click",function(b){b.preventDefault();var c;c=a.container.next().is(".customize-control-widget_form")?a.container.next().find(".widget-action:first"):a.container.prev().is(".customize-control-widget_form")?a.container.prev().find(".widget-action:first"):a.container.next(".customize-control-sidebar_widgets").find(".add-new-widget:first"),a.container.slideUp(function(){var b=e.getSidebarWidgetControlContainingWidget(a.params.widget_id);if(!b)throw new Error("Unable to find sidebars_widgets_control");var d=b.setting().slice(),f=d.indexOf(a.params.widget_id);if(-1===f)throw new Error("Widget is not in sidebar");d.splice(f,1),b.setting(d),c.focus()})});var c=function(){b.text(e.i18n.remove_btn_label),b.attr("title",e.i18n.remove_btn_tooltip)};a.params.is_new?wp.customize.bind("saved",c):c()},_getInputsSignature:function(b){var c=_(b).map(function(b){b=a(b);var c;return c=b.is("option")?[b.prop("nodeName"),b.prop("value")]:b.is(":checkbox, :radio")?[b.prop("type"),b.attr("id"),b.attr("name"),b.prop("value")]:[b.prop("nodeName"),b.attr("id"),b.attr("name"),b.attr("type")],c.join(",")});return c.join(";")},_getInputStatePropertyName:function(b){return b=a(b),b.is(":radio, :checkbox")?"checked":b.is("option")?"selected":"value"},getSidebarWidgetsControl:function(){var a=this,b="sidebars_widgets["+a.params.sidebar_id+"]",c=d.control(b);if(!c)throw new Error("Unable to locate sidebar_widgets control for "+a.params.sidebar_id);return c},updateWidget:function(b){var c=this;b=a.extend({instance:null,complete:null,ignore_active_element:!1},b);var d=b.instance,f=b.complete;c._update_count+=1;var g=c._update_count,h=c.container.find(".widget-content"),i=null,j=null,k=null;if(a.contains(c.container[0],document.activeElement)&&a(document.activeElement).is("[id]")){i=a(document.activeElement).prop("id");try{j=document.activeElement.selectionStart,k=document.activeElement.selectionEnd}catch(l){}}c.container.addClass("widget-form-loading"),c.container.addClass("previewer-loading");var m={};m.action=e.update_widget_ajax_action,m[e.update_widget_nonce_post_key]=e.update_widget_nonce_value;var n=a.param(m),o=h.find(":input, option");o.each(function(){var b=a(this),d=c._getInputStatePropertyName(this);b.data("state"+g,b.prop(d))}),n+=d?"&"+a.param({sanitized_widget_setting:JSON.stringify(d)}):"&"+o.serialize(),n+="&"+h.find("~ :input").serialize(),console.log(wp.ajax.settings.url,n);var p=a.post(wp.ajax.settings.url,n,function(d){if(d.success){var e=a("
    "+d.data.form+"
    ");c.hook("formUpdate",e);var l=e.find(":input, option"),m=c._getInputsSignature(o)===c._getInputsSignature(l);m?(o.each(function(d){var e=a(this),f=a(l[d]),h=c._getInputStatePropertyName(this),i=e.data("state"+g),j=f.prop(h);e.data("sanitized",j),i!==j?((b.ignore_active_element||!e.is(document.activeElement))&&e.prop(h,j),c.hook("unsanitaryField",e,j,i)):c.hook("sanitaryField",e,i)}),c.hook("formUpdated",e)):(h.html(e.html()),i&&a(document.getElementById(i)).prop({selectionStart:j,selectionEnd:k}).focus(),c.hook("formRefreshed"));var n=_(c.setting()).isEqual(d.data.instance);n?c.container.removeClass("previewer-loading"):(c.is_widget_updating=!0,c.setting(d.data.instance),c.is_widget_updating=!1),f&&f.call(c,null,{no_change:n,ajax_finished:!0})}else{console.log(d);var p="FAIL";if(d.data&&d.data.message&&(p=d.data.message),!f)throw new Error(p);f.call(c,p)}});p.fail(function(a,b){if(!f)throw new Error(b);f.call(c,b)}),p.always(function(){c.container.removeClass("widget-form-loading"),o.each(function(){a(this).removeData("state"+g)})})},expandControlSection:function(){var a=this.container.closest(".accordion-section");a.hasClass("open")||a.find(".accordion-section-title:first").trigger("click")},expandForm:function(){this.toggleForm(!0)},collapseForm:function(){this.toggleForm(!1)},toggleForm:function(a){var b=this,c=b.container.find("div.widget:first"),d=c.find(".widget-inside:first");if("undefined"==typeof a&&(a=!d.is(":visible")),d.is(":visible")!==a){var e;a?(wp.customize.control.each(function(a){b.params.type===a.params.type&&b!==a&&a.collapseForm()}),b.container.trigger("expand"),b.container.addClass("expanding"),e=function(){b.container.removeClass("expanding"),b.container.addClass("expanded"),b.container.trigger("expanded")},b.params.is_wide?d.animate({width:"show"},"fast",e):d.slideDown("fast",e)):(b.container.trigger("collapse"),b.container.addClass("collapsing"),e=function(){b.container.removeClass("collapsing"),b.container.removeClass("expanded"),b.container.trigger("collapsed")},b.params.is_wide?d.animate({width:"hide"},"fast",e):d.slideUp("fast",function(){c.css({width:"",margin:""}),e()}))}},focus:function(){var a=this;a.expandControlSection(),a.expandForm(),a.container.find(":focusable:first").focus().trigger("click")},getWidgetSidebarPosition:function(){var a=this,b=a.getSidebarWidgetsControl().setting(),c=b.indexOf(a.params.widget_id);if(-1===c)throw new Error("Widget was unexpectedly not present in the sidebar.");return c},moveUp:function(){this._moveWidgetByOne(-1)},moveDown:function(){this._moveWidgetByOne(1)},_moveWidgetByOne:function(a){var b=this,c=b.getWidgetSidebarPosition(),d=b.getSidebarWidgetsControl().setting,e=Array.prototype.slice.call(d()),f=e[c+a];e[c+a]=b.params.widget_id,e[c]=f,d(e)},toggleWidgetMoveArea:function(b){var c=this,d=c.container.find(".move-widget-area");"undefined"==typeof b&&(b=!d.hasClass("active")),b&&(d.find(".selected").removeClass("selected"),d.find("li").filter(function(){return a(this).data("id")===c.params.sidebar_id}).addClass("selected"),c.container.find(".move-widget-btn").prop("disabled",!0)),d.toggleClass("active",b)},getPreviewWidgetElement:function(){var a=this,b=e.getPreviewWindow().WidgetCustomizerPreview;return b.getSidebarWidgetElement(a.params.sidebar_id,a.params.widget_id)},scrollPreviewWidgetIntoView:function(){},highlightSectionAndControl:function(){var b,c=this;b=c.container.is(":hidden")?c.container.closest(".control-section"):c.container,a(".widget-customizer-highlighted").removeClass("widget-customizer-highlighted"),b.addClass("widget-customizer-highlighted"),setTimeout(function(){b.removeClass("widget-customizer-highlighted")},500)},highlightPreviewWidget:function(){var a=this,b=a.getPreviewWidgetElement(),c=b.closest("html");c.find(".widget-customizer-highlighted-widget").removeClass("widget-customizer-highlighted-widget"),b.addClass("widget-customizer-highlighted-widget"),setTimeout(function(){b.removeClass("widget-customizer-highlighted-widget")},500)}});var j=wp.customize.Previewer;return wp.customize.Previewer=j.extend({initialize:function(a,b){e.previewer=this,j.prototype.initialize.call(this,a,b),this.bind("refresh",this.refresh)}}),e.getSidebarWidgetControlContainingWidget=function(a){var b=null;return wp.customize.control.each(function(c){"sidebar_widgets"===c.params.type&&-1!==c.setting().indexOf(a)&&(b=c)}),b},e.getWidgetFormControlForWidget=function(a){var b=null;return wp.customize.control.each(function(c){"widget_form"===c.params.type&&c.params.widget_id===a&&(b=c)}),b},e.getPreviewWindow=function(){return a("#customize-preview").find("iframe").prop("contentWindow")},e.availableWidgetsPanel={active_sidebar_widgets_control:null,selected_widget_tpl:null,container:null,filter_input:null,setup:function(){var b=this;b.container=a("#available-widgets"),b.filter_input=a("#available-widgets-filter").find("input");var c=function(){e.available_widgets.each(function(c){var d=a("#widget-tpl-"+c.id);d.toggle(!c.get("is_disabled")),c.get("is_disabled")&&d.is(b.selected_widget_tpl)&&(b.selected_widget_tpl=null)})};e.available_widgets.on("change",c),c(),a("#customize-controls").on("click keydown",function(c){var d=a(c.target).is(".add-new-widget, .add-new-widget *");a("body").hasClass("adding-widget")&&!d&&b.close()}),e.previewer.bind("url",function(){b.close()}),b.container.find(".widget-tpl").on("click keypress",function(a){("keypress"!==a.type||13===a.which||32===a.which)&&b.submit(this)}),b.container.liveFilter("#available-widgets-filter input",".widget-tpl",{filterChildSelector:".widget-title h4",after:function(){var a=b.filter_input.val();if(b.selected_widget_tpl&&!b.selected_widget_tpl.is(":visible")&&(b.selected_widget_tpl.removeClass("selected"),b.selected_widget_tpl=null),b.selected_widget_tpl&&!a&&(b.selected_widget_tpl.removeClass("selected"),b.selected_widget_tpl=null),!b.selected_widget_tpl&&a){var c=b.container.find("> .widget-tpl:visible:first");c.length&&b.select(c)}}}),b.container.find(" > .widget-tpl").on("focus",function(){b.select(this)}),b.container.on("keydown",function(c){var d=13===c.which,e=27===c.which,f=40===c.which,g=38===c.which,h=null,i=b.container.find("> .widget-tpl:visible:first"),j=b.container.find("> .widget-tpl:visible:last"),k=a(c.target).is(b.filter_input);return f||g?(f?k?h=i:b.selected_widget_tpl&&0!==b.selected_widget_tpl.nextAll(".widget-tpl:visible").length&&(h=b.selected_widget_tpl.nextAll(".widget-tpl:visible:first")):g&&(k?h=j:b.selected_widget_tpl&&0!==b.selected_widget_tpl.prevAll(".widget-tpl:visible").length&&(h=b.selected_widget_tpl.prevAll(".widget-tpl:visible:first"))),b.select(h),void(h?h.focus():b.filter_input.focus())):void((!d||b.filter_input.val())&&(d?b.submit():e&&b.close({return_focus:!0})))})},select:function(b){var c=this;c.selected_widget_tpl=a(b),c.selected_widget_tpl.siblings(".widget-tpl").removeClass("selected"),c.selected_widget_tpl.addClass("selected")},submit:function(b){var c=this;if(b||(b=c.selected_widget_tpl),b&&c.active_sidebar_widgets_control){c.select(b);var d=a(c.selected_widget_tpl).data("widget-id"),f=e.available_widgets.findWhere({id:d});if(!f)throw new Error("Widget unexpectedly not found.");c.active_sidebar_widgets_control.addWidget(f.get("id_base")),c.close()}},open:function(b){var c=this;c.active_sidebar_widgets_control=b,_(b.getWidgetFormControls()).each(function(a){a.params.is_wide&&a.collapseForm()}),a("body").addClass("adding-widget"),c.container.find(".widget-tpl").removeClass("selected"),c.filter_input.focus()},close:function(b){var c=this;b=b||{},b.return_focus&&c.active_sidebar_widgets_control&&c.active_sidebar_widgets_control.container.find(".add-new-widget").focus(),c.active_sidebar_widgets_control=null,c.selected_widget_tpl=null,a("body").removeClass("adding-widget"),c.filter_input.val("")}},e}(jQuery);!function(a){a.fn.liveFilter=function(b,c,d){var e={filterChildSelector:null,filter:function(b,c){return a(b).text().toUpperCase().indexOf(c.toUpperCase())>=0},before:function(){},after:function(){}};d=a.extend(e,d);var f=a(this).find(c);d.filterChildSelector&&(f=f.find(d.filterChildSelector));var g=d.filter;a(b).keyup(function(){var b=a(this).val(),e=f.filter(function(){return g(this,b)}),h=f.not(e);d.filterChildSelector&&(e=e.parents(c),h=h.parents(c).hide()),d.before.call(this,e,h),e.show(),h.hide(),""===b&&(e.show(),h.show()),d.after.call(this,e,h)})}}(jQuery); \ No newline at end of file diff --git a/wp-includes/class-wp-customize-control.php b/wp-includes/class-wp-customize-control.php index d714c03c4d..6dc205d781 100644 --- a/wp-includes/class-wp-customize-control.php +++ b/wp-includes/class-wp-customize-control.php @@ -813,4 +813,76 @@ class WP_Customize_Header_Image_Control extends WP_Customize_Image_Control { foreach ( $this->default_headers as $choice => $header ) $this->print_header_image( $choice, $header ); } -} \ No newline at end of file +} + +/** + * Widget Area Customize Control Class + * + */ +class WP_Widget_Area_Customize_Control extends WP_Customize_Control { + public $type = 'sidebar_widgets'; + public $sidebar_id; + + public function to_json() { + parent::to_json(); + $exported_properties = array( 'sidebar_id' ); + foreach ( $exported_properties as $key ) { + $this->json[ $key ] = $this->$key; + } + } + + public function render_content() { + ?> + + + + + + + + + json[ $key ] = $this->$key; + } + } + + public function render_content() { + global $wp_registered_widgets; + require_once ABSPATH . '/wp-admin/includes/widgets.php'; + + $widget = $wp_registered_widgets[ $this->widget_id ]; + if ( ! isset( $widget['params'][0] ) ) { + $widget['params'][0] = array(); + } + + $args = array( + 'widget_id' => $widget['id'], + 'widget_name' => $widget['name'], + ); + + $args = wp_list_widget_controls_dynamic_sidebar( array( 0 => $args, 1 => $widget['params'][0] ) ); + echo WP_Customize_Widgets::get_widget_control( $args ); + } +} + diff --git a/wp-includes/class-wp-customize-manager.php b/wp-includes/class-wp-customize-manager.php index d68ff91cfd..802d30a5aa 100644 --- a/wp-includes/class-wp-customize-manager.php +++ b/wp-includes/class-wp-customize-manager.php @@ -61,6 +61,9 @@ final class WP_Customize_Manager { require( ABSPATH . WPINC . '/class-wp-customize-setting.php' ); require( ABSPATH . WPINC . '/class-wp-customize-section.php' ); require( ABSPATH . WPINC . '/class-wp-customize-control.php' ); + require( ABSPATH . WPINC . '/class-wp-customize-widgets.php' ); + + WP_Customize_Widgets::setup(); // This should be integrated. add_filter( 'wp_die_handler', array( $this, 'wp_die_handler' ) ); diff --git a/wp-includes/class-wp-customize-widgets.php b/wp-includes/class-wp-customize-widgets.php new file mode 100644 index 0000000000..8b0c6f6556 --- /dev/null +++ b/wp-includes/class-wp-customize-widgets.php @@ -0,0 +1,1264 @@ +get_stylesheet(), 'nonce', false ) + ); + + $is_ajax_widget_update = ( + ( defined( 'DOING_AJAX' ) && DOING_AJAX ) + && + self::get_post_value( 'action' ) === self::UPDATE_WIDGET_AJAX_ACTION + && + check_ajax_referer( self::UPDATE_WIDGET_AJAX_ACTION, self::UPDATE_WIDGET_NONCE_POST_KEY, false ) + ); + + $is_ajax_customize_save = ( + ( defined( 'DOING_AJAX' ) && DOING_AJAX ) + && + self::get_post_value( 'action' ) === 'customize_save' + && + check_ajax_referer( 'save-customize_' . $wp_customize->get_stylesheet(), 'nonce' ) + ); + + $is_valid_request = ( $is_ajax_widget_update || $is_customize_preview || $is_ajax_customize_save ); + if ( ! $is_valid_request ) { + return; + } + + // Input from customizer preview + if ( isset( $_POST['customized'] ) ) { + $customized = json_decode( self::get_post_value( 'customized' ), true ); + } + // Input from ajax widget update request + else { + $customized = array(); + $id_base = self::get_post_value( 'id_base' ); + $widget_number = (int) self::get_post_value( 'widget_number' ); + $option_name = 'widget_' . $id_base; + $customized[$option_name] = array(); + if ( false !== $widget_number ) { + $option_name .= '[' . $widget_number . ']'; + $customized[$option_name][$widget_number] = array(); + } + } + + $function = array( __CLASS__, 'prepreview_added_sidebars_widgets' ); + + $hook = 'option_sidebars_widgets'; + add_filter( $hook, $function ); + self::$_prepreview_added_filters[] = compact( 'hook', 'function' ); + + $hook = 'default_option_sidebars_widgets'; + add_filter( $hook, $function ); + self::$_prepreview_added_filters[] = compact( 'hook', 'function' ); + + foreach ( $customized as $setting_id => $value ) { + if ( preg_match( '/^(widget_.+?)(\[(\d+)\])?$/', $setting_id, $matches ) ) { + $body = sprintf( 'return %s::prepreview_added_widget_instance( $value, %s );', __CLASS__, var_export( $setting_id, true ) ); + $function = create_function( '$value', $body ); + $option = $matches[1]; + + $hook = sprintf( 'option_%s', $option ); + add_filter( $hook, $function ); + self::$_prepreview_added_filters[] = compact( 'hook', 'function' ); + + $hook = sprintf( 'default_option_%s', $option ); + add_filter( $hook, $function ); + self::$_prepreview_added_filters[] = compact( 'hook', 'function' ); + + /** + * Make sure the option is registered so that the update_option won't fail due to + * the filters providing a default value, which causes the update_option() to get confused. + */ + add_option( $option, array() ); + } + } + + self::$_customized = $customized; + } + + /** + * Ensure that newly-added widgets will appear in the widgets_sidebars. + * This is necessary because the customizer's setting preview filters are added after the widgets_init action, + * which is too late for the widgets to be set up properly. + * + * @param array $sidebars_widgets + * @return array + */ + static function prepreview_added_sidebars_widgets( $sidebars_widgets ) { + foreach ( self::$_customized as $setting_id => $value ) { + if ( preg_match( '/^sidebars_widgets\[(.+?)\]$/', $setting_id, $matches ) ) { + $sidebar_id = $matches[1]; + $sidebars_widgets[$sidebar_id] = $value; + } + } + return $sidebars_widgets; + } + + /** + * Ensure that newly-added widgets will have empty instances so that they will be recognized. + * This is necessary because the customizer's setting preview filters are added after the widgets_init action, + * which is too late for the widgets to be set up properly. + * + * @param array $instance + * @param string $setting_id + * @return array + */ + static function prepreview_added_widget_instance( $instance, $setting_id ) { + if ( isset( self::$_customized[$setting_id] ) ) { + $parsed_setting_id = self::parse_widget_setting_id( $setting_id ); + $widget_number = $parsed_setting_id['number']; + + // Single widget + if ( is_null( $widget_number ) ) { + if ( false === $instance && empty( $value ) ) { + $instance = array(); + } + } + // Multi widget + else if ( false === $instance || ! isset( $instance[$widget_number] ) ) { + if ( empty( $instance ) ) { + $instance = array( '_multiwidget' => 1 ); + } + if ( ! isset( $instance[$widget_number] ) ) { + $instance[$widget_number] = array(); + } + } + } + return $instance; + } + + /** + * Remove filters added in setup_widget_addition_previews() which ensure that + * widgets are populating the options during widgets_init + * + * @action wp_loaded + */ + static function remove_prepreview_filters() { + foreach ( self::$_prepreview_added_filters as $prepreview_added_filter ) { + remove_filter( $prepreview_added_filter['hook'], $prepreview_added_filter['function'] ); + } + self::$_prepreview_added_filters = array(); + } + + /** + * Make sure that all widgets get loaded into customizer; these actions are also done in the wp_ajax_save_widget() + * + * @see wp_ajax_save_widget() + * @action customize_controls_init + */ + static function customize_controls_init() { + do_action( 'load-widgets.php' ); + do_action( 'widgets.php' ); + do_action( 'sidebar_admin_setup' ); + } + + /** + * When in preview, invoke customize_register for settings after WordPress is + * loaded so that all filters have been initialized (e.g. Widget Visibility) + */ + static function schedule_customize_register( $wp_customize ) { + if ( is_admin() ) { // @todo for some reason, $wp_customize->is_preview() is true here? + self::customize_register( $wp_customize ); + } else { + add_action( 'wp', array( __CLASS__, 'customize_register' ) ); + } + } + + /** + * Register customizer settings and controls for all sidebars and widgets + * + * @action customize_register + */ + static function customize_register( $wp_customize = null ) { + global $wp_registered_widgets, $wp_registered_widget_controls; + if ( ! ( $wp_customize instanceof WP_Customize_Manager ) ) { + $wp_customize = $GLOBALS['wp_customize']; + } + + $sidebars_widgets = array_merge( + array( 'wp_inactive_widgets' => array() ), + array_fill_keys( array_keys( $GLOBALS['wp_registered_sidebars'] ), array() ), + wp_get_sidebars_widgets() + ); + + $new_setting_ids = array(); + + /** + * Register a setting for all widgets, including those which are active, inactive, and orphaned + * since a widget may get suppressed from a sidebar via a plugin (like Widget Visibility). + */ + foreach ( array_keys( $wp_registered_widgets ) as $widget_id ) { + $setting_id = self::get_setting_id( $widget_id ); + $setting_args = self::get_setting_args( $setting_id ); + $setting_args['sanitize_callback'] = array( __CLASS__, 'sanitize_widget_instance' ); + $setting_args['sanitize_js_callback'] = array( __CLASS__, 'sanitize_widget_js_instance' ); + $wp_customize->add_setting( $setting_id, $setting_args ); + $new_setting_ids[] = $setting_id; + } + + foreach ( $sidebars_widgets as $sidebar_id => $sidebar_widget_ids ) { + if ( empty( $sidebar_widget_ids ) ) { + $sidebar_widget_ids = array(); + } + $is_registered_sidebar = isset( $GLOBALS['wp_registered_sidebars'][$sidebar_id] ); + $is_inactive_widgets = ( 'wp_inactive_widgets' === $sidebar_id ); + $is_active_sidebar = ( $is_registered_sidebar && ! $is_inactive_widgets ); + + /** + * Add setting for managing the sidebar's widgets + */ + if ( $is_registered_sidebar || $is_inactive_widgets ) { + $setting_id = sprintf( 'sidebars_widgets[%s]', $sidebar_id ); + $setting_args = self::get_setting_args( $setting_id ); + $setting_args['sanitize_callback'] = array( __CLASS__, 'sanitize_sidebar_widgets' ); + $setting_args['sanitize_js_callback'] = array( __CLASS__, 'sanitize_sidebar_widgets_js_instance' ); + $wp_customize->add_setting( $setting_id, $setting_args ); + $new_setting_ids[] = $setting_id; + + /** + * Add section to contain controls + */ + $section_id = sprintf( 'sidebar-widgets-%s', $sidebar_id ); + if ( $is_active_sidebar ) { + $section_args = array( + 'title' => sprintf( __( 'Widgets: %s' ), $GLOBALS['wp_registered_sidebars'][$sidebar_id]['name'] ), + 'description' => $GLOBALS['wp_registered_sidebars'][$sidebar_id]['description'], + ); + $section_args = apply_filters( 'customizer_widgets_section_args', $section_args, $section_id, $sidebar_id ); + $wp_customize->add_section( $section_id, $section_args ); + + $control = new WP_Widget_Area_Customize_Control( + $wp_customize, + $setting_id, + array( + 'section' => $section_id, + 'sidebar_id' => $sidebar_id, + //'priority' => 99, // so it appears at the end + ) + ); + $new_setting_ids[] = $setting_id; + $wp_customize->add_control( $control ); + } + } + + /** + * Add a control for each active widget (located in a sidebar) + */ + foreach ( $sidebar_widget_ids as $i => $widget_id ) { + // Skip widgets that may have gone away due to a plugin being deactivated + if ( ! $is_active_sidebar || ! isset( $GLOBALS['wp_registered_widgets'][$widget_id] ) ) { + continue; + } + $registered_widget = $GLOBALS['wp_registered_widgets'][$widget_id]; + $setting_id = self::get_setting_id( $widget_id ); + $id_base = $GLOBALS['wp_registered_widget_controls'][$widget_id]['id_base']; + assert( false !== is_active_widget( $registered_widget['callback'], $registered_widget['id'], false, false ) ); + $control = new WP_Widget_Form_Customize_Control( + $wp_customize, + $setting_id, + array( + 'label' => $registered_widget['name'], + 'section' => $section_id, + 'sidebar_id' => $sidebar_id, + 'widget_id' => $widget_id, + 'widget_id_base' => $id_base, + 'priority' => $i, + 'width' => $wp_registered_widget_controls[$widget_id]['width'], + 'height' => $wp_registered_widget_controls[$widget_id]['height'], + 'is_wide' => self::is_wide_widget( $widget_id ), + ) + ); + $wp_customize->add_control( $control ); + } + } + + /** + * We have to register these settings later than customize_preview_init so that other + * filters have had a chance to run. + * @see self::schedule_customize_register() + */ + if ( did_action( 'customize_preview_init' ) ) { + foreach ( $new_setting_ids as $new_setting_id ) { + $wp_customize->get_setting( $new_setting_id )->preview(); + } + } + + self::remove_prepreview_filters(); + } + + /** + * Covert a widget_id into its corresponding customizer setting id (option name) + * + * @param string $widget_id + * @see _get_widget_id_base() + * @return string + */ + static function get_setting_id( $widget_id ) { + $parsed_widget_id = self::parse_widget_id( $widget_id ); + $setting_id = sprintf( 'widget_%s', $parsed_widget_id['id_base'] ); + if ( ! is_null( $parsed_widget_id['number'] ) ) { + $setting_id .= sprintf( '[%d]', $parsed_widget_id['number'] ); + } + return $setting_id; + } + + /** + * Core widgets which may have controls wider than 250, but can still be + * shown in the narrow customizer panel. The RSS and Text widgets in Core, + * for example, have widths of 400 and yet they still render fine in the + * customizer panel. This method will return all Core widgets as being + * not wide, but this can be overridden with the is_wide_widget_in_customizer + * filter. + * + * @param string $widget_id + * @return bool + */ + static function is_wide_widget( $widget_id ) { + global $wp_registered_widget_controls; + $parsed_widget_id = self::parse_widget_id( $widget_id ); + $width = $wp_registered_widget_controls[$widget_id]['width']; + $is_core = in_array( $parsed_widget_id['id_base'], self::$core_widget_id_bases ); + $is_wide = ( $width > 250 && ! $is_core ); + $is_wide = apply_filters( 'is_wide_widget_in_customizer', $is_wide, $widget_id ); + return $is_wide; + } + + /** + * Covert a widget ID into its id_base and number components + * + * @param string $widget_id + * @return array + */ + static function parse_widget_id( $widget_id ) { + $parsed = array( + 'number' => null, + 'id_base' => null, + ); + if ( preg_match( '/^(.+)-(\d+)$/', $widget_id, $matches ) ) { + $parsed['id_base'] = $matches[1]; + $parsed['number'] = intval( $matches[2] ); + } else { + // likely an old single widget + $parsed['id_base'] = $widget_id; + } + return $parsed; + } + + /** + * Convert a widget setting ID (option path) to its id_base and number components + * + * @throws Widget_Customizer_Exception + * @throws Exception + * + * @param string $setting_id + * @param array + * @return array + */ + static function parse_widget_setting_id( $setting_id ) { + if ( ! preg_match( '/^(widget_(.+?))(?:\[(\d+)\])?$/', $setting_id, $matches ) ) { + throw new Widget_Customizer_Exception( sprintf( 'Invalid widget setting ID: %s', $setting_id ) ); + } + $id_base = $matches[2]; + $number = isset( $matches[3] ) ? intval( $matches[3] ) : null; + return compact( 'id_base', 'number' ); + } + + /** + * Enqueue scripts and styles for customizer panel and export data to JS + * + * @action customize_controls_enqueue_scripts + */ + static function customize_controls_enqueue_deps() { + wp_enqueue_script( 'jquery-ui-sortable' ); + wp_enqueue_script( 'jquery-ui-droppable' ); + wp_enqueue_style( + 'widget-customizer', + admin_url( 'css/customize-widgets.css' ) + ); + wp_enqueue_script( + 'widget-customizer', + admin_url( 'js/customize-widgets.js' ), + array( 'jquery', 'wp-backbone', 'wp-util', 'customize-controls' ) + ); + + // Export available widgets with control_tpl removed from model + // since plugins need templates to be in the DOM + $available_widgets = array(); + foreach ( self::get_available_widgets() as $available_widget ) { + unset( $available_widget['control_tpl'] ); + $available_widgets[] = $available_widget; + } + + $widget_reorder_nav_tpl = sprintf( + '
    %2$s%4$s%6$s
    ', + esc_attr__( 'Move to another area...' ), + esc_html__( 'Move to another area...' ), + esc_attr__( 'Move down' ), + esc_html__( 'Move down' ), + esc_attr__( 'Move up' ), + esc_html__( 'Move up' ) + ); + + $move_widget_area_tpl = str_replace( + array( '{description}', '{btn}' ), + array( + esc_html__( 'Select an area to move this widget into:' ), + esc_html__( 'Move' ), + ), + ' +
    +

    {description}

    +
      + <% _.each( sidebars, function ( sidebar ){ %> +
    • <%- sidebar.name %>
    • + <% }); %> +
    +
    + +
    +
    + ' + ); + + // Why not wp_localize_script? Because we're not localizing, and it forces values into strings + global $wp_scripts; + $exports = array( + 'update_widget_ajax_action' => self::UPDATE_WIDGET_AJAX_ACTION, + 'update_widget_nonce_value' => wp_create_nonce( self::UPDATE_WIDGET_AJAX_ACTION ), + 'update_widget_nonce_post_key' => self::UPDATE_WIDGET_NONCE_POST_KEY, + 'registered_sidebars' => array_values( $GLOBALS['wp_registered_sidebars'] ), + 'registered_widgets' => $GLOBALS['wp_registered_widgets'], + 'available_widgets' => $available_widgets, // @todo Merge this with registered_widgets + 'i18n' => array( + 'save_btn_label' => _x( 'Apply', 'button to save changes to a widget' ), + 'save_btn_tooltip' => _x( 'Save and preview changes before publishing them.', 'tooltip on the widget save button' ), + 'remove_btn_label' => _x( 'Remove', 'link to move a widget to the inactive widgets sidebar' ), + 'remove_btn_tooltip' => _x( 'Trash widget by moving it to the inactive widgets sidebar.', 'tooltip on btn a widget to move it to the inactive widgets sidebar' ), + ), + 'tpl' => array( + 'widget_reorder_nav' => $widget_reorder_nav_tpl, + 'move_widget_area' => $move_widget_area_tpl, + ), + ); + foreach ( $exports['registered_widgets'] as &$registered_widget ) { + unset( $registered_widget['callback'] ); // may not be JSON-serializeable + } + + $wp_scripts->add_data( + 'widget-customizer', + 'data', + sprintf( 'var WidgetCustomizer_exports = %s;', json_encode( $exports ) ) + ); + } + + /** + * Render the widget form control templates into the DOM so that plugin scripts can manipulate them + * + * @action customize_controls_print_footer_scripts + */ + static function output_widget_control_templates() { + ?> +
    +
    +
    + +
    + +
    + +
    + +
    +
    + 'option', + 'capability' => 'edit_theme_options', + 'transport' => 'refresh', + 'default' => array(), + ); + $args = array_merge( $args, $overrides ); + $args = apply_filters( 'widget_customizer_setting_args', $args, $id ); + return $args; + } + + /** + * Make sure that a sidebars_widgets[x] only ever consists of actual widget IDs. + * Used as sanitize_callback for each sidebars_widgets setting. + * + * @param array $widget_ids + * @return array + */ + static function sanitize_sidebar_widgets( $widget_ids ) { + global $wp_registered_widgets; + $widget_ids = array_map( 'strval', (array) $widget_ids ); + $sanitized_widget_ids = array(); + foreach ( $widget_ids as $widget_id ) { + if ( array_key_exists( $widget_id, $wp_registered_widgets ) ) { + $sanitized_widget_ids[] = $widget_id; + } + } + return $sanitized_widget_ids; + } + + /** + * Special filter for Settings Revisions plugin until it can handle + * dynamically creating settings. + * + * @param mixed $value + * @param stdClass|WP_Customize_Setting $setting + * @return mixed + */ + static function temp_customize_sanitize_js( $value, $setting ) { + if ( preg_match( '/^widget_/', $setting->id ) && $setting->type === 'option' ) { + $value = self::sanitize_widget_js_instance( $value ); + } + return $value; + } + + /** + * Build up an index of all available widgets for use in Backbone models + * + * @see wp_list_widgets() + * @return array + */ + static function get_available_widgets() { + static $available_widgets = array(); + if ( ! empty( $available_widgets ) ) { + return $available_widgets; + } + + global $wp_registered_widgets, $wp_registered_widget_controls; + require_once ABSPATH . '/wp-admin/includes/widgets.php'; // for next_widget_id_number() + + $sort = $wp_registered_widgets; + usort( $sort, array( __CLASS__, '_sort_name_callback' ) ); + $done = array(); + + foreach ( $sort as $widget ) { + if ( in_array( $widget['callback'], $done, true ) ) { // We already showed this multi-widget + continue; + } + + $sidebar = is_active_widget( $widget['callback'], $widget['id'], false, false ); + $done[] = $widget['callback']; + + if ( ! isset( $widget['params'][0] ) ) { + $widget['params'][0] = array(); + } + + $available_widget = $widget; + unset( $available_widget['callback'] ); // not serializable to JSON + + $args = array( + 'widget_id' => $widget['id'], + 'widget_name' => $widget['name'], + '_display' => 'template', + ); + + $is_disabled = false; + $is_multi_widget = ( + isset( $wp_registered_widget_controls[$widget['id']]['id_base'] ) + && + isset( $widget['params'][0]['number'] ) + ); + if ( $is_multi_widget ) { + $id_base = $wp_registered_widget_controls[$widget['id']]['id_base']; + $args['_temp_id'] = "$id_base-__i__"; + $args['_multi_num'] = next_widget_id_number( $id_base ); + $args['_add'] = 'multi'; + } else { + $args['_add'] = 'single'; + if ( $sidebar && 'wp_inactive_widgets' !== $sidebar ) { + $is_disabled = true; + } + $id_base = $widget['id']; + } + + $list_widget_controls_args = wp_list_widget_controls_dynamic_sidebar( array( 0 => $args, 1 => $widget['params'][0] ) ); + $control_tpl = self::get_widget_control( $list_widget_controls_args ); + + // The properties here are mapped to the Backbone Widget model + $available_widget = array_merge( + $available_widget, + array( + 'temp_id' => isset( $args['_temp_id'] ) ? $args['_temp_id'] : null, + 'is_multi' => $is_multi_widget, + 'control_tpl' => $control_tpl, + 'multi_number' => ( $args['_add'] === 'multi' ) ? $args['_multi_num'] : false, + 'is_disabled' => $is_disabled, + 'id_base' => $id_base, + 'transport' => 'refresh', + 'width' => $wp_registered_widget_controls[$widget['id']]['width'], + 'height' => $wp_registered_widget_controls[$widget['id']]['height'], + 'is_wide' => self::is_wide_widget( $widget['id'] ), + ) + ); + + $available_widgets[] = $available_widget; + } + return $available_widgets; + } + + /** + * Replace with inline closure once on PHP 5.3: + * sort( $array, function ( $a, $b ) { return strnatcasecmp( $a['name'], $b['name'] ); } ); + * + * @access private + */ + static function _sort_name_callback( $a, $b ) { + return strnatcasecmp( $a['name'], $b['name'] ); + } + + /** + * Invoke wp_widget_control() but capture the output buffer and transform the markup + * so that it can be used in the customizer. + * + * @see wp_widget_control() + * @param array $args + * @return string + */ + static function get_widget_control( $args ) { + ob_start(); + call_user_func_array( 'wp_widget_control', $args ); + $replacements = array( + '
    ' => '
    ', + '' => '
    ', + ); + $control_tpl = ob_get_clean(); + $control_tpl = str_replace( array_keys( $replacements ), array_values( $replacements ), $control_tpl ); + return $control_tpl; + } + + /** + * Add hooks for the customizer preview + * + * @action customize_preview_init + */ + static function customize_preview_init() { + add_filter( 'sidebars_widgets', array( __CLASS__, 'preview_sidebars_widgets' ), 1 ); + add_action( 'wp_enqueue_scripts', array( __CLASS__, 'customize_preview_enqueue_deps' ) ); + add_action( 'wp_footer', array( __CLASS__, 'export_preview_data' ), 9999 ); + } + + /** + * When previewing, make sure the proper previewing widgets are used. Because wp_get_sidebars_widgets() + * gets called early at init (via wp_convert_widget_settings()) and can set global variable + * $_wp_sidebars_widgets to the value of get_option( 'sidebars_widgets' ) before the customizer + * preview filter is added, we have to reset it after the filter has been added. + * + * @filter sidebars_widgets + */ + static function preview_sidebars_widgets( $sidebars_widgets ) { + $sidebars_widgets = get_option( 'sidebars_widgets' ); + unset( $sidebars_widgets['array_version'] ); + return $sidebars_widgets; + } + + /** + * Enqueue scripts for the customizer preview + * + * @action wp_enqueue_scripts + */ + static function customize_preview_enqueue_deps() { + wp_enqueue_script( + 'customize-preview-widgets', + includes_url( 'js/customize-preview-widgets.js' ), + array( 'jquery', 'wp-util', 'customize-preview' ) + ); + + /* + wp_enqueue_style( + 'widget-customizer-preview', + 'widget-customizer-preview.css' + ); + */ + + // Why not wp_localize_script? Because we're not localizing, and it forces values into strings + global $wp_scripts; + $exports = array( + 'registered_sidebars' => array_values( $GLOBALS['wp_registered_sidebars'] ), + 'registered_widgets' => $GLOBALS['wp_registered_widgets'], + 'i18n' => array( + 'widget_tooltip' => __( 'Press shift and then click to edit widget in customizer...' ), + ), + 'request_uri' => wp_unslash( $_SERVER['REQUEST_URI'] ), + ); + foreach ( $exports['registered_widgets'] as &$registered_widget ) { + unset( $registered_widget['callback'] ); // may not be JSON-serializeable + } + $wp_scripts->add_data( + 'customize-preview-widgets', + 'data', + sprintf( 'var WidgetCustomizerPreview_exports = %s;', json_encode( $exports ) ) + ); + } + + /** + * At the very end of the page, at the very end of the wp_footer, communicate the sidebars that appeared on the page + * + * @action wp_footer + */ + static function export_preview_data() { + wp_print_scripts( array( 'customize-preview-widgets' ) ); + ?> + + $widget_ids ) { + if ( in_array( $sidebar_id, self::$rendered_sidebars ) ) { + continue; + } + if ( isset( $GLOBALS['wp_registered_sidebars'][$sidebar_id] ) && is_array( $widget_ids ) && in_array( $widget['id'], $widget_ids ) ) { + self::$rendered_sidebars[] = $sidebar_id; + } + } + } + + /** + * Keep track of the times that is_active_sidebar() is called in the template, and assume that this + * means that the sidebar would be rendered on the template if there were widgets populating it. + * + * @see http://core.trac.wordpress.org/ticket/25368 + * @filter temp_is_active_sidebar + */ + static function tally_sidebars_via_is_active_sidebar_calls( $is_active, $sidebar_id ) { + if ( isset( $GLOBALS['wp_registered_sidebars'][$sidebar_id] ) ) { + self::$rendered_sidebars[] = $sidebar_id; + } + // We may need to force this to true, and also force-true the value for temp_dynamic_sidebar_has_widgets + // if we want to ensure that there is an area to drop widgets into, if the sidebar is empty. + return $is_active; + } + + /** + * Keep track of the times that dynamic_sidebar() is called in the template, and assume that this + * means that the sidebar would be rendered on the template if there were widgets populating it. + * + * @see http://core.trac.wordpress.org/ticket/25368 + * @filter temp_dynamic_sidebar_has_widgets + */ + static function tally_sidebars_via_dynamic_sidebar_calls( $has_widgets, $sidebar_id ) { + if ( isset( $GLOBALS['wp_registered_sidebars'][$sidebar_id] ) ) { + self::$rendered_sidebars[] = $sidebar_id; + } + // We may need to force this to true, and also force-true the value for temp_is_active_sidebar + // if we want to ensure that there is an area to drop widgets into, if the sidebar is empty. + return $has_widgets; + } + + /** + * Serialize an instance and hash it with the AUTH_KEY; when a JS value is + * posted back to save, this instance hash key is used to ensure that the + * serialized_instance was not tampered with, but that it had originated + * from WordPress and so is sanitized. + * + * @param array $instance + * @return string + */ + protected static function get_instance_hash_key( $instance ) { + $hash = md5( AUTH_KEY . serialize( $instance ) ); + return $hash; + } + + /** + * Unserialize the JS-instance for storing in the options. It's important + * that this filter only get applied to an instance once. + * + * @see Widget_Customizer::sanitize_widget_js_instance() + * + * @param array $value + * @return array + */ + static function sanitize_widget_instance( $value ) { + if ( $value === array() ) { + return $value; + } + $invalid = ( + empty( $value['is_widget_customizer_js_value'] ) + || + empty( $value['instance_hash_key'] ) + || + empty( $value['encoded_serialized_instance'] ) + ); + if ( $invalid ) { + return null; + } + $decoded = base64_decode( $value['encoded_serialized_instance'], true ); + if ( false === $decoded ) { + return null; + } + $instance = unserialize( $decoded ); + if ( false === $instance ) { + return null; + } + if ( self::get_instance_hash_key( $instance ) !== $value['instance_hash_key'] ) { + return null; + } + return $instance; + } + + /** + * Convert widget instance into JSON-representable format + * + * @see Widget_Customizer::sanitize_widget_instance() + * + * @param array $value + * @return array + */ + static function sanitize_widget_js_instance( $value ) { + if ( empty( $value['is_widget_customizer_js_value'] ) ) { + $serialized = serialize( $value ); + $value = array( + 'encoded_serialized_instance' => base64_encode( $serialized ), + 'title' => empty( $value['title'] ) ? '' : $value['title'], + 'is_widget_customizer_js_value' => true, + 'instance_hash_key' => self::get_instance_hash_key( $value ), + ); + } + return $value; + } + + /** + * Strip out widget IDs for widgets which are no longer registered, such + * as the case when a plugin orphans a widget in a sidebar when it is deactivated. + * + * @param array $widget_ids + * @return array + */ + static function sanitize_sidebar_widgets_js_instance( $widget_ids ) { + global $wp_registered_widgets; + $widget_ids = array_values( array_intersect( $widget_ids, array_keys( $wp_registered_widgets ) ) ); + return $widget_ids; + } + + /** + * Find and invoke the widget update and control callbacks. Requires that + * $_POST be populated with the instance data. + * + * @throws Widget_Customizer_Exception + * @throws Exception + * + * @param string $widget_id + * @return array + */ + static function call_widget_update( $widget_id ) { + global $wp_registered_widget_updates, $wp_registered_widget_controls; + + $options_transaction = new Options_Transaction(); + + try { + $options_transaction->start(); + $parsed_id = self::parse_widget_id( $widget_id ); + $option_name = 'widget_' . $parsed_id['id_base']; + + /** + * If a previously-sanitized instance is provided, populate the input vars + * with its values so that the widget update callback will read this instance + */ + $added_input_vars = array(); + if ( ! empty( $_POST['sanitized_widget_setting'] ) ) { + $sanitized_widget_setting = json_decode( self::get_post_value( 'sanitized_widget_setting' ), true ); + if ( empty( $sanitized_widget_setting ) ) { + throw new Widget_Customizer_Exception( 'Malformed sanitized_widget_setting' ); + } + $instance = self::sanitize_widget_instance( $sanitized_widget_setting ); + if ( is_null( $instance ) ) { + throw new Widget_Customizer_Exception( 'Unsanitary sanitized_widget_setting' ); + } + if ( ! is_null( $parsed_id['number'] ) ) { + $value = array(); + $value[$parsed_id['number']] = $instance; + $key = 'widget-' . $parsed_id['id_base']; + $_REQUEST[$key] = $_POST[$key] = wp_slash( $value ); + $added_input_vars[] = $key; + } else { + foreach ( $instance as $key => $value ) { + $_REQUEST[$key] = $_POST[$key] = wp_slash( $value ); + $added_input_vars[] = $key; + } + } + } + + /** + * Invoke the widget update callback + */ + foreach ( (array) $wp_registered_widget_updates as $name => $control ) { + if ( $name === $parsed_id['id_base'] && is_callable( $control['callback'] ) ) { + ob_start(); + call_user_func_array( $control['callback'], $control['params'] ); + ob_end_clean(); + break; + } + } + + // Clean up any input vars that were manually added + foreach ( $added_input_vars as $key ) { + unset( $_POST[$key] ); + unset( $_REQUEST[$key] ); + } + + /** + * Make sure the expected option was updated + */ + if ( 0 !== $options_transaction->count() ) { + if ( count( $options_transaction->options ) > 1 ) { + throw new Widget_Customizer_Exception( sprintf( 'Widget %1$s unexpectedly updated more than one option.', $widget_id ) ); + } + $updated_option_name = key( $options_transaction->options ); + if ( $updated_option_name !== $option_name ) { + throw new Widget_Customizer_Exception( sprintf( 'Widget %1$s updated option "%2$s", but expected "%3$s".', $widget_id, $updated_option_name, $option_name ) ); + } + } + + /** + * Obtain the widget control with the updated instance in place + */ + ob_start(); + $form = $wp_registered_widget_controls[$widget_id]; + if ( $form ) { + call_user_func_array( $form['callback'], $form['params'] ); + } + $form = ob_get_clean(); + + /** + * Obtain the widget instance + */ + $option = get_option( $option_name ); + if ( null !== $parsed_id['number'] ) { + $instance = $option[$parsed_id['number']]; + } else { + $instance = $option; + } + + $options_transaction->rollback(); + return compact( 'instance', 'form' ); + } + catch ( Exception $e ) { + $options_transaction->rollback(); + throw $e; + } + } + + /** + * Allow customizer to update a widget using its form, but return the new + * instance info via Ajax instead of saving it to the options table. + * Most code here copied from wp_ajax_save_widget() + * + * @see wp_ajax_save_widget + * @todo Reuse wp_ajax_save_widget now that we have option transactions? + * @action wp_ajax_update_widget + */ + static function wp_ajax_update_widget() { + $generic_error = __( 'An error has occurred. Please reload the page and try again.' ); + + try { + if ( ! check_ajax_referer( self::UPDATE_WIDGET_AJAX_ACTION, self::UPDATE_WIDGET_NONCE_POST_KEY, false ) ) { + throw new Widget_Customizer_Exception( __( 'Nonce check failed. Reload and try again?' ) ); + } + if ( ! current_user_can( 'edit_theme_options' ) ) { + throw new Widget_Customizer_Exception( __( 'Current user cannot!' ) ); + } + if ( ! isset( $_POST['widget-id'] ) ) { + throw new Widget_Customizer_Exception( __( 'Incomplete request' ) ); + } + + unset( $_POST[self::UPDATE_WIDGET_NONCE_POST_KEY], $_POST['action'] ); + + do_action( 'load-widgets.php' ); + do_action( 'widgets.php' ); + do_action( 'sidebar_admin_setup' ); + + $widget_id = self::get_post_value( 'widget-id' ); + $parsed_id = self::parse_widget_id( $widget_id ); + $id_base = $parsed_id['id_base']; + + if ( isset( $_POST['widget-' . $id_base] ) && is_array( $_POST['widget-' . $id_base] ) && preg_match( '/__i__|%i%/', key( $_POST['widget-' . $id_base] ) ) ) { + throw new Widget_Customizer_Exception( 'Cannot pass widget templates to create new instances; apply template vars in JS' ); + } + + $updated_widget = self::call_widget_update( $widget_id ); // => {instance,form} + $form = $updated_widget['form']; + $instance = self::sanitize_widget_js_instance( $updated_widget['instance'] ); + + wp_send_json_success( compact( 'form', 'instance' ) ); + } + catch( Exception $e ) { + if ( $e instanceof Widget_Customizer_Exception ) { + $message = $e->getMessage(); + } else { + error_log( sprintf( '%s in %s: %s', get_class( $e ), __FUNCTION__, $e->getMessage() ) ); + $message = $generic_error; + } + wp_send_json_error( compact( 'message' ) ); + } + } +} + +class Widget_Customizer_Exception extends Exception {} + +class Options_Transaction { + + /** + * @var array $options values updated while transaction is open + */ + public $options = array(); + + protected $_ignore_transients = true; + protected $_is_current = false; + protected $_operations = array(); + + function __construct( $ignore_transients = true ) { + $this->_ignore_transients = $ignore_transients; + } + + /** + * Determine whether or not the transaction is open + * @return bool + */ + function is_current() { + return $this->_is_current; + } + + /** + * @param $option_name + * @return boolean + */ + function is_option_ignored( $option_name ) { + return ( $this->_ignore_transients && 0 === strpos( $option_name, '_transient_' ) ); + } + + /** + * Get the number of operations performed in the transaction + * @return bool + */ + function count() { + return count( $this->_operations ); + } + + /** + * Start keeping track of changes to options, and cache their new values + */ + function start() { + $this->_is_current = true; + add_action( 'added_option', array( $this, '_capture_added_option' ), 10, 2 ); + add_action( 'updated_option', array( $this, '_capture_updated_option' ), 10, 3 ); + add_action( 'delete_option', array( $this, '_capture_pre_deleted_option' ), 10, 1 ); + add_action( 'deleted_option', array( $this, '_capture_deleted_option' ), 10, 1 ); + } + + /** + * @action added_option + * @param $option_name + * @param $new_value + */ + function _capture_added_option( $option_name, $new_value ) { + if ( $this->is_option_ignored( $option_name ) ) { + return; + } + $this->options[$option_name] = $new_value; + $operation = 'add'; + $this->_operations[] = compact( 'operation', 'option_name', 'new_value' ); + } + + /** + * @action updated_option + * @param string $option_name + * @param mixed $old_value + * @param mixed $new_value + */ + function _capture_updated_option( $option_name, $old_value, $new_value ) { + if ( $this->is_option_ignored( $option_name ) ) { + return; + } + $this->options[$option_name] = $new_value; + $operation = 'update'; + $this->_operations[] = compact( 'operation', 'option_name', 'old_value', 'new_value' ); + } + + protected $_pending_delete_option_autoload; + protected $_pending_delete_option_value; + + /** + * It's too bad the old_value and autoload aren't passed into the deleted_option action + * @action delete_option + * @param string $option_name + */ + function _capture_pre_deleted_option( $option_name ) { + if ( $this->is_option_ignored( $option_name ) ) { + return; + } + global $wpdb; + $autoload = $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name = %s", $option_name ) ); // db call ok; no-cache ok + $this->_pending_delete_option_autoload = $autoload; + $this->_pending_delete_option_value = get_option( $option_name ); + } + + /** + * @action deleted_option + * @param string $option_name + */ + function _capture_deleted_option( $option_name ) { + if ( $this->is_option_ignored( $option_name ) ) { + return; + } + unset( $this->options[$option_name] ); + $operation = 'delete'; + $old_value = $this->_pending_delete_option_value; + $autoload = $this->_pending_delete_option_autoload; + $this->_operations[] = compact( 'operation', 'option_name', 'old_value', 'autoload' ); + } + + /** + * Undo any changes to the options since start() was called + */ + function rollback() { + remove_action( 'updated_option', array( $this, '_capture_updated_option' ), 10, 3 ); + remove_action( 'added_option', array( $this, '_capture_added_option' ), 10, 2 ); + remove_action( 'delete_option', array( $this, '_capture_pre_deleted_option' ), 10, 1 ); + remove_action( 'deleted_option', array( $this, '_capture_deleted_option' ), 10, 1 ); + while ( 0 !== count( $this->_operations ) ) { + $option_operation = array_pop( $this->_operations ); + if ( 'add' === $option_operation['operation'] ) { + delete_option( $option_operation['option_name'] ); + } + else if ( 'delete' === $option_operation['operation'] ) { + add_option( $option_operation['option_name'], $option_operation['old_value'], null, $option_operation['autoload'] ); + } + else if ( 'update' === $option_operation['operation'] ) { + update_option( $option_operation['option_name'], $option_operation['old_value'] ); + } + else { + throw new Exception( 'Unexpected operation' ); + } + } + $this->_is_current = false; + } +} diff --git a/wp-includes/js/customize-preview-widgets.js b/wp-includes/js/customize-preview-widgets.js new file mode 100644 index 0000000000..4a99f6510f --- /dev/null +++ b/wp-includes/js/customize-preview-widgets.js @@ -0,0 +1,115 @@ +/*global jQuery, WidgetCustomizerPreview_exports */ +/*exported WidgetCustomizerPreview */ +var WidgetCustomizerPreview = (function ($) { + 'use strict'; + + var self = { + rendered_sidebars: {}, // @todo Make rendered a property of the Backbone model + rendered_widgets: {}, // @todo Make rendered a property of the Backbone model + registered_sidebars: [], // @todo Make a Backbone collection + registered_widgets: {}, // @todo Make array, Backbone collection + widget_selectors: [], + render_widget_ajax_action: null, + render_widget_nonce_value: null, + render_widget_nonce_post_key: null, + preview: null, + i18n: {}, + + init: function () { + this.buildWidgetSelectors(); + this.highlightControls(); + + self.preview.bind( 'active', function() { + self.preview.send( 'rendered-sidebars', self.rendered_sidebars ); // @todo Only send array of IDs + self.preview.send( 'rendered-widgets', self.rendered_widgets ); // @todo Only send array of IDs + } ); + }, + + /** + * Calculate the selector for the sidebar's widgets based on the registered sidebar's info + */ + buildWidgetSelectors: function () { + $.each( self.registered_sidebars, function ( i, sidebar ) { + var widget_tpl = [ + sidebar.before_widget.replace('%1$s', '').replace('%2$s', ''), + sidebar.before_title, + sidebar.after_title, + sidebar.after_widget + ].join(''); + var empty_widget = $(widget_tpl); + var widget_selector = empty_widget.prop('tagName'); + var widget_classes = empty_widget.prop('className').replace(/^\s+|\s+$/g, ''); + if ( widget_classes ) { + widget_selector += '.' + widget_classes.split(/\s+/).join('.'); + } + self.widget_selectors.push(widget_selector); + }); + }, + + /** + * Obtain a widget instance if it was added to the provided sidebar + * This addresses a race condition where a widget is moved between sidebars + * We cannot use ID selector because jQuery will only return the first one + * that matches. We have to resort to an [id] attribute selector + * + * @param {String} sidebar_id + * @param {String} widget_id + * @return {jQuery} + */ + getSidebarWidgetElement: function ( sidebar_id, widget_id ) { + return $( '[id=' + widget_id + ']' ).filter( function () { + return $( this ).data( 'widget_customizer_sidebar_id' ) === sidebar_id; + } ); + }, + + /** + * + */ + highlightControls: function() { + + var selector = this.widget_selectors.join(','); + + $(selector).attr( 'title', self.i18n.widget_tooltip ); + + $(document).on( 'mouseenter', selector, function () { + var control = parent.WidgetCustomizer.getWidgetFormControlForWidget( $(this).prop('id') ); + if ( control ) { + control.highlightSectionAndControl(); + } + }); + + // Open expand the widget control when shift+clicking the widget element + $(document).on( 'click', selector, function ( e ) { + if ( ! e.shiftKey ) { + return; + } + e.preventDefault(); + var control = parent.WidgetCustomizer.getWidgetFormControlForWidget( $(this).prop('id') ); + if ( control ) { + control.focus(); + } + }); + } + + }; + + $.extend(self, WidgetCustomizerPreview_exports); + + /** + * Capture the instance of the Preview since it is private + */ + var OldPreview = wp.customize.Preview; + wp.customize.Preview = OldPreview.extend( { + initialize: function( params, options ) { + self.preview = this; + OldPreview.prototype.initialize.call( this, params, options ); + } + } ); + + // @todo on customize ready? + $(function () { + self.init(); + }); + + return self; +}( jQuery )); diff --git a/wp-includes/js/customize-preview-widgets.min.js b/wp-includes/js/customize-preview-widgets.min.js new file mode 100644 index 0000000000..ea0e77d3fd --- /dev/null +++ b/wp-includes/js/customize-preview-widgets.min.js @@ -0,0 +1 @@ +var WidgetCustomizerPreview=function(a){"use strict";var b={rendered_sidebars:{},rendered_widgets:{},registered_sidebars:[],registered_widgets:{},widget_selectors:[],render_widget_ajax_action:null,render_widget_nonce_value:null,render_widget_nonce_post_key:null,preview:null,i18n:{},init:function(){this.buildWidgetSelectors(),this.highlightControls(),b.preview.bind("active",function(){b.preview.send("rendered-sidebars",b.rendered_sidebars),b.preview.send("rendered-widgets",b.rendered_widgets)})},buildWidgetSelectors:function(){a.each(b.registered_sidebars,function(c,d){var e=[d.before_widget.replace("%1$s","").replace("%2$s",""),d.before_title,d.after_title,d.after_widget].join(""),f=a(e),g=f.prop("tagName"),h=f.prop("className").replace(/^\s+|\s+$/g,"");h&&(g+="."+h.split(/\s+/).join(".")),b.widget_selectors.push(g)})},getSidebarWidgetElement:function(b,c){return a("[id="+c+"]").filter(function(){return a(this).data("widget_customizer_sidebar_id")===b})},highlightControls:function(){var c=this.widget_selectors.join(",");a(c).attr("title",b.i18n.widget_tooltip),a(document).on("mouseenter",c,function(){var b=parent.WidgetCustomizer.getWidgetFormControlForWidget(a(this).prop("id"));b&&b.highlightSectionAndControl()}),a(document).on("click",c,function(b){if(b.shiftKey){b.preventDefault();var c=parent.WidgetCustomizer.getWidgetFormControlForWidget(a(this).prop("id"));c&&c.focus()}})}};a.extend(b,WidgetCustomizerPreview_exports);var c=wp.customize.Preview;return wp.customize.Preview=c.extend({initialize:function(a,d){b.preview=this,c.prototype.initialize.call(this,a,d)}}),a(function(){b.init()}),b}(jQuery); \ No newline at end of file