diff --git a/.distignore b/.distignore new file mode 100755 index 0000000..7970658 --- /dev/null +++ b/.distignore @@ -0,0 +1,22 @@ +# A set of files you probably don't want in your WordPress.org distribution +.distignore +.editorconfig +.git +.gitignore +.travis.yml +.DS_Store +Thumbs.db +bin +composer.json +composer.lock +Gruntfile.js +package.json +phpunit.xml +multisite.xml +phpunit.xml.dist +phpcs.ruleset.xml +README.md +wp-cli.local.yml +tests +vendor +node_modules diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..79207a4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# WordPress Coding Standards +# https://make.wordpress.org/core/handbook/coding-standards/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = 4 + +[{.jshintrc,*.json,*.yml}] +indent_style = space +indent_size = 2 + +[{*.txt,wp-config-sample.php}] +end_of_line = crlf diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..20830a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +Thumbs.db +wp-cli.local.yml +node_modules/ diff --git a/.travis.yml b/.travis.yml new file mode 100755 index 0000000..d11bd20 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,29 @@ +language: php + +notifications: + email: + on_success: never + on_failure: change + +branches: + only: + - master + +php: + - 5.3 + - 5.6 + +env: + - WP_VERSION=latest WP_MULTISITE=0 + - WP_VERSION=3.7 WP_MULTISITE=0 + - WP_VERSION=4.7-alpha-38178-src WP_MULTISITE=0 + +matrix: + include: + - php: 5.3 + env: WP_VERSION=latest WP_MULTISITE=1 + +before_script: + - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION + +script: phpunit diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100755 index 0000000..78af68e --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,56 @@ +module.exports = function( grunt ) { + + 'use strict'; + var banner = '/**\n * <%= pkg.homepage %>\n * Copyright (c) <%= grunt.template.today("yyyy") %>\n * This file is generated automatically. Do not edit.\n */\n'; + // Project configuration + grunt.initConfig( { + + pkg: grunt.file.readJSON( 'package.json' ), + + addtextdomain: { + options: { + textdomain: 'fieldmanager-beta-customize', + }, + target: { + files: { + src: [ '*.php', '**/*.php', '!node_modules/**', '!php-tests/**', '!bin/**' ] + } + } + }, + + wp_readme_to_markdown: { + options: { + screenshot_url: './assets/{screenshot}.png', + }, + your_target: { + files: { + 'README.md': 'readme.txt' + } + }, + }, + + makepot: { + target: { + options: { + domainPath: '/languages', + mainFile: 'fieldmanager-beta-customize.php', + potFilename: 'fieldmanager-beta-customize.pot', + potHeaders: { + poedit: true, + 'x-poedit-keywordslist': true + }, + type: 'wp-plugin', + updateTimestamp: true + } + } + }, + } ); + + grunt.loadNpmTasks( 'grunt-wp-i18n' ); + grunt.loadNpmTasks( 'grunt-wp-readme-to-markdown' ); + grunt.registerTask( 'i18n', ['addtextdomain', 'makepot'] ); + grunt.registerTask( 'readme', ['wp_readme_to_markdown'] ); + + grunt.util.linefeed = '\n'; + +}; diff --git a/README.md b/README.md index d0efa06..5a2a8ff 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ -# fieldmanager-beta-customize \ No newline at end of file +# Fieldmanager Beta: Customize # +**Contributors:** [dlh](https://profiles.wordpress.org/dlh), [alleyinteractive](https://profiles.wordpress.org/alleyinteractive) +**Requires at least:** 4.4 +**Tested up to:** 4.6.1 +**Stable tag:** 0.1.0 +**License:** GPLv2 or later +**License URI:** http://www.gnu.org/licenses/gpl-2.0.html + +A Fieldmanager Beta plugin for the Customize Context. + +## Description ## + +This is the proposed Customize context for Fieldmanager. You can install the plugin alongside a stable Fieldmanager release to help test and refine the context. + +The official Pull Request for the Customize context, plus tests, is [on GitHub](https://github.com/alleyinteractive/wordpress-fieldmanager/pull/399). + +## Installation ## + +1. Install and activate [Fieldmanager](https://github.com/alleyinteractive/wordpress-fieldmanager). +2. Install and activate this plugin. +3. Use the `fm_customize` context action to instantiate your fields. For example: + + add_action( 'fm_customize', function () { + $fm = new Fieldmanager_TextField( 'My Field', [ 'name' => 'foo' ] ); + fm_beta_customize_add_to_customizer( 'My Section', $fm ); + } ); + +For more code examples, browse `php/demos/class-fieldmanager-beta-customize-demo.php`. To see the demos in action in the Customizer, place `add_action( 'fm_customize', 'fm_beta_customize_demo' )` in your plugin or theme. + +## Screenshots ## + +### 1. Fieldmanager mingling with other sections in the Customizer. ### +![Fieldmanager mingling with other sections in the Customizer.](./assets/screenshot-1.png) + +### 2. Fieldmanager fields in the Customizer. ### +![Fieldmanager fields in the Customizer.](./assets/screenshot-2.png) + +### 3. Detail from the demos bundled with this plugin. ### +![Detail from the demos bundled with this plugin.](./assets/screenshot-3.png) + + +## Changelog ## + +### 0.1.0 ### +* Initial release. + +## Fieldmanager-specific quirks ## + +* RichTextAreas: These are supported via the `Fieldmanager_Beta_Customize_RichTextArea` class included with this plugin. Use a `Fieldmanager_Beta_Customize_RichTextArea` in place of `Fieldmanager_RichTextArea` when you want to use TinyMCE in the Customizer. +* Scripts and styles: Some Fieldmanager JavaScript and CSS files require changes for the Customize context. This plugin includes the updated versions of those files and filters Fieldmanager to return them. diff --git a/assets/screenshot-1.png b/assets/screenshot-1.png new file mode 100644 index 0000000..8bf832a Binary files /dev/null and b/assets/screenshot-1.png differ diff --git a/assets/screenshot-2.png b/assets/screenshot-2.png new file mode 100644 index 0000000..84fe993 Binary files /dev/null and b/assets/screenshot-2.png differ diff --git a/assets/screenshot-3.png b/assets/screenshot-3.png new file mode 100644 index 0000000..8ff3722 Binary files /dev/null and b/assets/screenshot-3.png differ diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh new file mode 100755 index 0000000..cf709c2 --- /dev/null +++ b/bin/install-wp-tests.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash + +if [ $# -lt 3 ]; then + echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-latest} +SKIP_DB_CREATE=${6-false} + +WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress/} + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then + WP_TESTS_TAG="tags/$WP_VERSION" +elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + WP_TESTS_TAG="trunk" +else + # http serves a single offer, whereas https serves multiple. we only want one + download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json + grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json + LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') + if [[ -z "$LATEST_VERSION" ]]; then + echo "Latest WordPress version could not be found" + exit 1 + fi + WP_TESTS_TAG="tags/$LATEST_VERSION" +fi + +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + return; + fi + + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p /tmp/wordpress-nightly + download https://wordpress.org/nightly-builds/wordpress-latest.zip /tmp/wordpress-nightly/wordpress-nightly.zip + unzip -q /tmp/wordpress-nightly/wordpress-nightly.zip -d /tmp/wordpress-nightly/ + mv /tmp/wordpress-nightly/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + else + local ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + download https://wordpress.org/${ARCHIVE_NAME}.tar.gz /tmp/wordpress.tar.gz + tar --strip-components=1 -zxmf /tmp/wordpress.tar.gz -C $WP_CORE_DIR + fi + + download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php +} + +install_test_suite() { + # portable in-place argument for both GNU sed and Mac OSX sed + if [[ $(uname -s) == 'Darwin' ]]; then + local ioption='-i .bak' + else + local ioption='-i' + fi + + # set up testing suite if it doesn't yet exist + if [ ! -d $WP_TESTS_DIR ]; then + # set up testing suite + mkdir -p $WP_TESTS_DIR + svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + fi + + if [ ! -f wp-tests-config.php ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + # remove all forward slashes in the end + WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + +} + +install_db() { + + if [ ${SKIP_DB_CREATE} = "true" ]; then + return 0 + fi + + # parse DB_HOST for port or socket references + local PARTS=(${DB_HOST//\:/ }) + local DB_HOSTNAME=${PARTS[0]}; + local DB_SOCK_OR_PORT=${PARTS[1]}; + local EXTRA="" + + if ! [ -z $DB_HOSTNAME ] ; then + if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then + EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" + elif ! [ -z $DB_SOCK_OR_PORT ] ; then + EXTRA=" --socket=$DB_SOCK_OR_PORT" + elif ! [ -z $DB_HOSTNAME ] ; then + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + # create database + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_wp +install_test_suite +install_db diff --git a/css/fieldmanager.css b/css/fieldmanager.css new file mode 100644 index 0000000..98c57c4 --- /dev/null +++ b/css/fieldmanager.css @@ -0,0 +1,412 @@ +/* Fields */ +.fm-wrapper select, +.fm-wrapper textarea, +.fm-wrapper input[type="text"], +.fm-wrapper input[type="password"], +.fm-wrapper input[type="datetime"], +.fm-wrapper input[type="datetime-local"], +.fm-wrapper input[type="date"], +.fm-wrapper input[type="month"], +.fm-wrapper input[type="time"], +.fm-wrapper input[type="week"], +.fm-wrapper input[type="number"], +.fm-wrapper input[type="email"], +.fm-wrapper input[type="url"], +.fm-wrapper input[type="search"], +.fm-wrapper input[type="tel"], +.fm-wrapper input[type="color"] { + max-width: 100%; +} + +.fm-hidden { + display: none; +} + +.fm-label-inline { + margin: 0 5px 0 0; +} + +.fm-label-inline.fm-label-after { + margin: 0 0 0 5px; +} + +/* Groups */ +.fm-group { + margin-bottom: 5px; + background-color: #fff; +} + +.fm-group-inner, +.fm-group-label-wrapper { + border: solid 1px #dfdfdf; + -webkit-box-shadow: 0 1px 1px rgba(0,0,0,.04); + box-shadow: 0 1px 1px rgba(0,0,0,.04); +} + +.fm-group-inner { + padding: 10px; + border-top: none; +} + +.fm-group h4, +.fm-group div.fm-group-label-wrapper { + cursor: default; + font-size: 13px; + padding: 5px 5px 7px; + margin: 0; + background-color: #fafafa; + color: #222; +} + +.fm-group-label-wrapper.fmjs-drag-header:hover, +.fm-group-label-wrapper.fmjs-collapsible-handle:hover { + border: 1px solid #999; +} + +.fm-group-label-wrapper.fmjs-collapsible-handle .fmjs-remove { + display:none; +} + +.fm-group-label-wrapper.fmjs-collapsible-handle:hover .fmjs-remove { + display: block; +} + +.fm-group-label-wrapper.fmjs-collapsible-handle:hover .toggle-indicator { + color: #777; +} + +.fm-group .toggle-indicator { + color: #a0a5aa; + margin-left: 5px; +} + +.fm-group .toggle-indicator:before { + vertical-align: middle; +} + +.fm-item .fmjs-collapsible-handle .toggle-indicator:before { + content: "\f142"; + display: inline-block; + font: normal 20px/1 dashicons; + speak: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-decoration: none !important; +} + +.fm-item .fmjs-collapsible-handle.closed .toggle-indicator:before { + content: "\f140"; +} + +.fmjs-collapsible-handle .toggle-indicator { + float: right; +} + +div.fm-group-label-wrapper { + overflow: auto; +} + +.fm-group div.fm-group-label-wrapper h4 { + background-color: transparent; + background-image: none; + margin: 0; + padding: 3px 5px 0 5px; + border-bottom: none; + float: left; +} + +/* Sortables and repeatables */ +.fm-item, .fmjs-sortable .sortable-placeholder { + margin-bottom: 10px; +} + +.fm-group .fm-add-another-wrapper { + margin-bottom: 15px; +} + +.fmjs-proto { + display: none; +} + +.fmjs-removable-element, .fmjs-clearable-element { + float: left; +} + +.fmjs-removable:before, +.fmjs-removable:after { + content: " "; + display: table; +} +.fmjs-removable:after { + clear: both; +} + +.fmjs-drag-icon:before, .fmjs-remove:before, .fmjs-clear:before { + font-family: dashicons; + font-size: 15px; + line-height: 1; + color: #a0a5aa; +} + +.fmjs-remove, .fmjs-clear { + margin: 4px 0 0 5px; + display: block; + height: 15px; + width: 15px; + float: left; + text-decoration: none; +} + +.fmjs-remove:before, .fmjs-clear:before { + content: "\f153"; +} + +.fmjs-remove:hover { + cursor: pointer; +} + +.fmjs-remove:hover:before, .fmjs-clear:hover:before { + color: #d54e21; +} + +.fmjs-drag-icon { + height: 15px; + width: 15px; + margin: 3px 3px 0 0; + float: left; +} + +.fmjs-drag-icon:before { + content: "\f156"; +} + +.fmjs-drag-icon:hover { + cursor: move; + color: #777; +} + +.fm-group div.fm-group-label-wrapper.fmjs-drag-header { + cursor: move; +} + +div.fm-group-label-wrapper .fmjs-remove { + float: right; +} + +.fm-dialog { + background: #fff; + border: solid 1px #555; +} + +.fm-dialog h3 { + margin: 0; + background: #222; + color: #cfcfcf; + padding: 3px 10px; + font: 12px "Lucida Grande", Verdana, Arial, sans-serif; +} + +.fm-dialog .inner { + margin: 10px; +} + +.fm-item-description { + font-style: italic; +} + +.fm-wrapper div.fm-submenu { + background-color: transparent; + display: none; + position: absolute; + top: -9px; + left: 146px; + z-index: 999; + overflow: hidden; + list-style: none; + padding: 0px 5px 5px 0px; +} + +.fm-wrapper .fm-submenu, .fm-wrapper .fm-submenu-wrap { + width: 145px; +} + +.fm-wrapper .fm-submenu * { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.fm-wrapper .fm-submenu li { + cursor: pointer; + display: block; + padding: 2px 5px; + margin: 2px 0px; +} + +.fm-wrapper li .fm-submenu-wrap { + border-width: 1px 1px 1px 0; + border-style: solid solid solid none; + position: relative; + -webkit-border-bottom-right-radius: 3px; + -webkit-border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + border-top-right-radius: 3px; +} + +.fm-wrapper .fm-submenu-wrap { + -moz-box-shadow: 2px 2px 5px rgba(0,0,0,0.4); + -webkit-box-shadow: 2px 2px 5px rgba(0,0,0,0.4); + box-shadow: 2px 2px 5px rgba(0,0,0,0.4); +} + +.fm-wrapper .fm-submenu-wrap, .fm-wrapper .fm-submenu ul { + border-color: #dfdfdf; +} + +.fm-wrapper .fm-submenu ul { + background-color: #fff; + padding: 4px 0; +} + +.fm-wrapper div.fm-submenu.sub-open { + display: block; + padding: 0 8px 8px 0; +} + +.fm-wrapper .fm-submenu li:hover, .fm-wrapper .fm-submenu li:focus { + background-color: #EAF2FA; + color: #333; +} + +.chzn-container-multi .chzn-choices .search-field input { + height: 21px !important; +} + +/* Grid editor CSS */ +.grid-toggle-wrapper { + padding: 7px; + margin: 5px 5px 5px 0; + overflow: scroll; + max-height: 500px; +} + +.grid-toggle-wrapper.with-grid { + padding: 5px; + background: #fff; + border: solid 2px #ccc; +} + +.grid-activate { + display: block; + height: 20px; + padding: 5px; + width: 200px; + border: solid 1px #000; + margin: 5px 0; + text-decoration: none; +} + +.grid-activate:before { + font-family: dashicons; + font-size: 17px; + line-height: 1; + margin-right: 5px; + content: '\f509'; + vertical-align: middle; + color: #a0a5aa; +} + +.grid-activate:hover { + border: solid 1px #d54e21; +} + +.grid-activate:hover:before { + color: #777; +} + +.with-grid .grid-activate:hover { + border: none; +} + +.with-grid .grid-activate { + background: #fff; + border: none; +} + +/* Rich text area */ +.fm-richtext .fm-element { + background: #fff; + height: 100px; + width: 600px; + padding: 5px; + margin: 10px 0; +} + +a.fm-delete { + color: #bc0b0b; + text-decoration: none; +} + +a.fm-delete:hover { + color: #f00; +} + +.media-wrapper { + margin: 5px 0 15px 15px; + padding-left: 10px; + border-left: solid 1px #ccc; +} + +.fm-submenu-form-wrapper { + padding: 20px 0; +} + +.fm-datepicker-popup { + width: 100px; +} + +.fm-datepicker-time { + width: 30px; +} + +.form-field input[type="checkbox"], +.form-field input[type="radio"], +.form-field input.fm-add-another, +.form-field .fm-media-button { + width: auto; +} +.form-field .fm-option label, +.form-field .fm-checkbox label { + display: inline; +} + +.wp-customizer .ui-autocomplete, +.wp-customizer .ui-datepicker { + /* Hoist jQuery UI popups over who-knows-what in the Customizer. */ + z-index: 500000 !important; +} + +.customize-control input.fm-datepicker-popup { + /* Override Customizer defaults; datepickers have multiple inputs on one line. */ + width: 100px; +} + +.customize-control input.fm-datepicker-time { + /* Override Customizer defaults; datepickers have multiple inputs on one line. */ + width: 30px; +} + +.fm-datepicker-time-wrapper select { + /* Override Customizer defaults; datepickers have multiple inputs on one line. */ + min-width: 0; +} + +.wp-customizer .fmjs-removable .fmjs-drag-icon { + /* Nudge for the Customizer. */ + margin-top: 7px; +} + +.wp-customizer .fmjs-removable .fmjs-drag-icon + .fmjs-removable-element { + /* Nudge for the Customizer. */ + max-width: 80%; +} diff --git a/fieldmanager-beta-customize.php b/fieldmanager-beta-customize.php new file mode 100755 index 0000000..2527560 --- /dev/null +++ b/fieldmanager-beta-customize.php @@ -0,0 +1,145 @@ + array( 'title' => $args ) ); + } + + return new Fieldmanager_Beta_Context_Customize( $args, $fm ); +} + +/** + * Instantiate the bundled context demos. + */ +function fm_beta_customize_demo() { + Fieldmanager_Beta_Customize_Demo::instance(); +} diff --git a/js/fieldmanager-autocomplete.js b/js/fieldmanager-autocomplete.js new file mode 100644 index 0000000..2719072 --- /dev/null +++ b/js/fieldmanager-autocomplete.js @@ -0,0 +1,92 @@ +(function( $ ) { + +fm.autocomplete = { + + prepare_options: function( raw_opts ) { + var opts = []; + for ( var k in raw_opts ) opts.push( { label: raw_opts[k], value: k } ); + return opts; + }, + + enable_autocomplete: function() { + $( 'input.fm-autocomplete:visible' ).each( function() { + if ( !$( this ).hasClass( 'fm-autocomplete-enabled' ) ) { + var ac_params = {}; + var $el = $( this ); + var $hidden = $el.siblings( 'input[type=hidden]' ).first(); + ac_params.select = function( e, ui ) { + e.preventDefault(); + $el.val( ui.item.label ); + $hidden.val( ui.item.value ).trigger( 'change' ); + }; + ac_params.focus = function( e, ui ) { + e.preventDefault(); + $el.val( ui.item.label ); + } + if ( $el.data( 'action' ) ) { + ac_params.source = function( request, response ) { + // Check for custom args + var custom_args_js_event = $el.data( 'customArgsJsEvent' ); + var custom_data = ''; + if ( 'undefined' !== typeof custom_args_js_event && null !== custom_args_js_event ) { + var custom_result = $el.triggerHandler( custom_args_js_event ); + if ( 'undefined' !== typeof custom_result && null !== custom_result ) { + custom_data = custom_result; + } + } + + $.post( ajaxurl, { + action: $el.data( 'action' ), + fm_context: $el.data( 'context' ), + fm_subcontext: $el.data( 'subcontext' ), + fm_autocomplete_search: request.term, + fm_search_nonce: fm_search.nonce, + fm_custom_args: custom_data + }, function( result ) { + response( result ); + } ); + }; + } else if ( $el.data( 'options' ) ) { + ac_params.source = fm.autocomplete.prepare_options( $el.data( 'options' ) ); + } + + // data-exact-match is a minimized attribute (see Fieldmanager_Field::get_element_attributes) + if ( typeof $el.data( 'exact-match' ) !== 'undefined' ) { + ac_params.change = function( e, ui ) { + if ( !ui.item ) { + $hidden.val( '' ); + $el.val( '' ); + } + }; + + // Handle the user deleting the input text, which is not an AC 'change'. + $( this ).on( 'keyup', function( e ) { + if ( e.target && '' === $( e.target ).val() ) { + $hidden.val( '' ).trigger( 'change' ); + } + }); + } else { + $( this ).on( 'keyup', function( e ) { + if ( e.keyCode == 27 || e.keyCode == 13 ) { + return; + } + + $hidden.val( '=' + $el.val() ); + } ); + } + + $( this ).autocomplete( ac_params ); + $( this ).addClass( 'fm-autocomplete-enabled' ); + } + } ); + } +} + +$( document ).ready( fm.autocomplete.enable_autocomplete ); +$( document ).on( 'fm_collapsible_toggle fm_added_element fm_displayif_toggle fm_activate_tab', fm.autocomplete.enable_autocomplete ); +$( document ).on( 'focus', + 'input[class*="fm-autocomplete"]:not(.fm-autocomplete-enabled)', + fm.autocomplete.enable_autocomplete +); + +})( jQuery ); diff --git a/js/fieldmanager-colorpicker.js b/js/fieldmanager-colorpicker.js new file mode 100644 index 0000000..19fbde6 --- /dev/null +++ b/js/fieldmanager-colorpicker.js @@ -0,0 +1,31 @@ +( function( $ ) { + + fm.colorpicker = { + init: function() { + $( '.fm-colorpicker-popup:visible' ).wpColorPicker({ + change: function ( e, ui ) { + // Make sure the input's value attribute also changes. + $( this ).attr( 'value', ui.color.toString() ); + fm.colorpicker.triggerUpdateEvent( this ); + }, + clear: function () { + // Make sure the input's value attribute also changes. + $( this ).attr( 'value', '' ); + fm.colorpicker.triggerUpdateEvent( this ); + }, + }); + }, + triggerUpdateEvent: function ( el ) { + /** + * Trigger a common event for a value 'change' or 'clear'. + * + * @var {Element} Colorpicker element. + */ + $( document ).trigger( 'fm_colorpicker_update', el ); + } + }; + + $( document ).ready( fm.colorpicker.init ); + $( document ).on( 'fm_collapsible_toggle fm_added_element fm_displayif_toggle fm_activate_tab', fm.colorpicker.init ); + +} )( jQuery ); \ No newline at end of file diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js new file mode 100644 index 0000000..4624a7b --- /dev/null +++ b/js/fieldmanager-customize.js @@ -0,0 +1,256 @@ +/* global document, jQuery, wp, _, fm */ +/** + * Integrate Fieldmanager and the Customizer. + * + * @param {function} $ jQuery + * @param {function} api Customizer API. + * @param {function} _ Underscore + * @param {Object} fm Fieldmanager API. + */ +(function( $, api, _, fm ) { + 'use strict'; + + fm.customize = { + /** + * jQuery selector targeting all elements to include in a Fieldmanager setting value. + * + * @type {String} + */ + targetSelector: '.fm-element', + + /** + * Set the values of all Fieldmanager controls. + */ + setEachControl: function () { + var that = this; + + api.control.each(function( control ) { + that.setControl( control ); + }); + }, + + /** + * Set the value of any Fieldmanager control with a given element in its container. + * + * @param {Element} el Element to look for. + */ + setControlsContainingElement: function ( el ) { + var that = this; + + api.control.each(function( control ) { + if ( control.container.find( el ).length ) { + that.setControl( control ); + } + }); + }, + + /** + * Set a Fieldmanager setting to its control's form values. + * + * @param {Object} control Customizer Control object. + * @return {Object} The updated Control. + */ + setControl: function ( control ) { + var $element; + var serialized; + var value; + + if ( 'fieldmanager' !== control.params.type ) { + return; + } + + if ( ! control.setting ) { + return; + } + + $element = control.container.find( this.targetSelector ); + + if ( $element.serializeJSON ) { + serialized = $element.serializeJSON(); + value = serialized[ control.id ]; + } else { + value = $element.serialize(); + } + + return control.setting.set( value ); + }, + }; + + /** + * Fires when an .fm-element input triggers a 'change' event. + * + * @param {Event} e Event object. + */ + var onFmElementChange = function( e ) { + fm.customize.setControlsContainingElement( e.target ); + }; + + /** + * Fires when an .fm-element input triggers a 'keyup' event. + * + * @param {Event} e Event object. + */ + var onFmElementKeyup = function( e ) { + var $target = $( e.target ); + + // Ignore [Escape] and [Enter]. + if ( 27 === e.keyCode || 13 === e.keyCode ) { + return; + } + + if ( $target.hasClass( 'fm-autocomplete' ) ) { + /* + * Don't update when typing into the autocomplete input. The hidden + * field actually contains the value and is handled onFmElementChange(). + */ + return; + } + + fm.customize.setControlsContainingElement( e.target ); + }; + + /** + * Fires when a Fieldmanager object is dropped while sorting. + * + * @param {Event} e Event object. + * @param {Element} el The sorted element. + */ + var onFmSortableDrop = function ( e, el ) { + fm.customize.setControlsContainingElement( el ); + }; + + /** + * Fires when Fieldmanager adds a new element in a repeatable field. + * + * @param {Event} e Event object. + */ + var onFmAddedElement = function( e ) { + fm.customize.setControlsContainingElement( e.target ); + }; + + /** + * Fires when an item is selected and previewed in a Fieldmanager media field. + * + * @param {Event} e Event object. + * @param {jQuery} $wrapper .media-wrapper jQuery object. + * @param {Object} attachment Attachment attributes. + * @param {Object} wp Global WordPress JS API. + */ + var onFieldmanagerMediaPreview = function( e, $wrapper, attachment, wp ) { + fm.customize.setControlsContainingElement( e.target ); + }; + + /** + * Fires after TinyMCE initializes in a Fieldmanager richtext field. + * + * @param {Event} e Event object. + * @param {Object} ed TinyMCE instance. + */ + var onFmRichtextInit = function( e, ed ) { + ed.on( 'keyup AddUndo', function () { + ed.save(); + fm.customize.setControlsContainingElement( document.getElementById( ed.id ) ); + } ); + }; + + /** + * Fires after a Fieldmanager colorpicker field updates. + * + * @param {Event} e Event object. + * @param {Element} el Colorpicker element. + */ + var onFmColorpickerUpdate = function( e, el ) { + fm.customize.setControlsContainingElement( el ); + }; + + /** + * Fires after clicking the "Remove" link of a Fieldmanager media field. + * + * @param {Event} e Event object. + */ + var onFmMediaRemoveClick = function ( e ) { + // The control no longer contains the element, so set all of them. + fm.customize.setEachControl(); + }; + + /** + * Fires after clicking the "Remove" link of a Fieldmanager repeatable field. + * + * @param {Event} e Event object. + */ + var onFmjsRemoveClick = function ( e ) { + // The control no longer contains the element, so set all of them. + fm.customize.setEachControl(); + }; + + /** + * Fires when a Customizer Section expands. + * + * @param {Object} section Customizer Section object. + */ + var onSectionExpanded = function( section ) { + /* + * Trigger a Fieldmanager event when a Customizer section expands. + * + * We bind to sections whether or not they have FM controls in case a + * control is added dynamically. + */ + $( document ).trigger( 'fm_customize_control_section_expanded' ); + + if ( fm.richtextarea ) { + fm.richtextarea.add_rte_to_visible_textareas(); + } + + if ( fm.colorpicker ) { + fm.colorpicker.init(); + } + + /* + * Reserialize any Fieldmanager controls in this section with null + * values. We assume null indicates nothing has been saved to the + * database, so we want to make sure default values take effect in the + * preview and are submitted on save as they would be in other contexts. + */ + _.each( section.controls(), function ( control ) { + if ( + control.settings.default && + null === control.settings.default.get() + ) { + fm.customize.setControl( control ); + } + }); + }; + + /** + * Fires when the Customizer is loaded. + */ + var ready = function() { + var $document = $( document ); + + $document.on( 'keyup', '.fm-element', onFmElementKeyup ); + $document.on( 'change', '.fm-element', onFmElementChange ); + $document.on( 'click', '.fm-media-remove', onFmMediaRemoveClick ); + $document.on( 'click', '.fmjs-remove', onFmjsRemoveClick ); + $document.on( 'fm_sortable_drop', onFmSortableDrop ); + $document.on( 'fieldmanager_media_preview', onFieldmanagerMediaPreview ); + $document.on( 'fm_richtext_init', onFmRichtextInit ); + $document.on( 'fm_colorpicker_update', onFmColorpickerUpdate ); + }; + + /** + * Fires when a Customizer Section is added. + * + * @param {Object} section Customizer Section object. + */ + var addSection = function( section ) { + // It would be more efficient to do this only when adding an FM control to a section. + section.container.bind( 'expanded', function () { + onSectionExpanded( section ); + } ); + }; + + if ( typeof api !== 'undefined' ) { + api.bind( 'ready', ready ); + api.section.bind( 'add', addSection ); + } +})( jQuery, wp.customize, _, fm ); diff --git a/js/fieldmanager-datepicker.js b/js/fieldmanager-datepicker.js new file mode 100644 index 0000000..24cacd2 --- /dev/null +++ b/js/fieldmanager-datepicker.js @@ -0,0 +1,18 @@ +( function( $ ) { + fm.datepicker = { + add_datepicker: function() { + $( '.fm-datepicker-popup:visible' ).each( function() { + if ( !$( this ).hasClass( 'fm-has-date-picker' ) ) { + var opts = $( this ).data( 'datepicker-opts' ); + $( this ).datepicker( opts ).addClass( 'fm-has-date-picker' ); + } + } ); + } + } + + $( document ).ready( fm.datepicker.add_datepicker ); + $( document ).on( + 'fm_collapsible_toggle fm_added_element fm_displayif_toggle fm_activate_tab fm_customize_control_section_expanded', + fm.datepicker.add_datepicker + ); +} ) ( jQuery ); diff --git a/js/fieldmanager.js b/js/fieldmanager.js new file mode 100644 index 0000000..ade2daf --- /dev/null +++ b/js/fieldmanager.js @@ -0,0 +1,282 @@ +var fm = {}; + +( function( $ ) { + +var dynamic_seq = 0; + +var init_sortable_container = function( el ) { + if ( !$( el ).hasClass( 'ui-sortable' ) ) { + $( el ).sortable( { + handle: '.fmjs-drag', + items: '> .fm-item', + placeholder: "sortable-placeholder", + forcePlaceholderSize: true, + start: function( e, ui ) { + $( document ).trigger( 'fm_sortable_drag', el ); + }, + stop: function( e, ui ) { + var $parent = ui.item.parents( '.fm-wrapper' ).first(); + fm_renumber( $parent ); + $( document ).trigger( 'fm_sortable_drop', el ); + } + } ); + } +} + +var init_sortable = function() { + $( '.fmjs-sortable' ).each( function() { + if ( $( this ).is( ':visible' ) ) { + init_sortable_container( this ); + } else { + var sortable = this; + $( sortable ).parents( '.fm-group' ).first().bind( 'fm_collapsible_toggle', function() { + init_sortable_container( sortable ); + } ); + } + } ); +} + +var init_label_macros = function() { + // Label macro magic. + $( '.fm-label-with-macro' ).each( function( label ) { + $( this ).data( 'label-original', $( this ).html() ); + var src = $( this ).parents( '.fm-group' ).first().find( $( this ).data( 'label-token' ) ); + if ( src.length > 0 ) { + var $src = $( src[0] ); + if ( typeof $src.val === 'function' ) { + var $label = $( this ); + var title_macro = function() { + var token = ''; + if ( $src.prop( 'tagName' ) == 'SELECT' ) { + var $option = $src.find( 'option:selected' ); + if ( $option.val() ) { + token = $option.text(); + } + } else { + token = $src.val(); + } + if ( token.length > 0 ) { + $label.html( $label.data( 'label-format' ).replace( '%s', token ) ); + } else { + $label.html( $label.data( 'label-original' ) ); + } + }; + $src.on( 'change keyup', title_macro ); + title_macro(); + } + } + } ); +} + +var fm_renumber = function( $wrappers ) { + $wrappers.each( function() { + var level_pos = $( this ).data( 'fm-array-position' ) - 0; + var order = 0; + if ( level_pos > 0 ) { + $( this ).find( '> .fm-item' ).each( function() { + if ( $( this ).hasClass( 'fmjs-proto' ) ) { + return; // continue + } + $( this ).find( '.fm-element, .fm-incrementable' ).each( function() { + var fname = $(this).attr( 'name' ); + if ( fname ) { + fname = fname.replace( /\]/g, '' ); + parts = fname.split( '[' ); + if ( parts[ level_pos ] != order ) { + parts[ level_pos ] = order; + var new_fname = parts[ 0 ] + '[' + parts.slice( 1 ).join( '][' ) + ']'; + $( this ).attr( 'name', new_fname ); + if ( $( this ).attr( 'id' ) && $( this ).attr( 'id' ).match( '-proto' ) && ! new_fname.match( 'proto' ) ) { + $( this ).attr( 'id', 'fm-edit-dynamic-' + dynamic_seq ); + if ( $( this ).parent().hasClass( 'fm-option' ) ) { + $( this ).parent().find( 'label' ).attr( 'for', 'fm-edit-dynamic-' + dynamic_seq ); + } else { + var parent = $( this ).closest( '.fm-item' ); + if ( parent.length && parent.find( '.fm-label label' ).length ) { + parent.find( '.fm-label label' ).attr( 'for', 'fm-edit-dynamic-' + dynamic_seq ); + } + } + dynamic_seq++; + return; // continue; + } + } + } + if ( $( this ).hasClass( 'fm-incrementable' ) ) { + $( this ).attr( 'id', 'fm-edit-dynamic-' + dynamic_seq ); + dynamic_seq++; + } + } ); + order++; + } ); + } + $( this ).find( '.fm-wrapper' ).each( function() { + fm_renumber( $( this ) ); + } ); + } ); +} + +/** + * Get data attribute display-value(s). + * + * Accounts for jQuery converting string to number automatically. + * + * @param HTMLDivElement el Wrapper with the data attribute. + * @return string|number|array Single string or number, or array if data attr contains CSV. + */ +var getCompareValues = function( el ) { + var values = $( el ).data( 'display-value' ); + try { + values = values.split( ',' ); + } catch( e ) { + // If jQuery already converted string to number. + values = [ values ]; + } + return values; +}; + +var match_value = function( values, match_string ) { + for ( var index in values ) { + if ( values[index] == match_string ) { + return true; + } + } + return false; +} + +fm_add_another = function( $element ) { + var el_name = $element.data( 'related-element' ) + , limit = $element.data( 'limit' ) - 0 + , siblings = $element.parent().siblings( '.fm-item' ).not( '.fmjs-proto' ) + , add_more_position = $element.data( 'add-more-position' ) || "bottom"; + + if ( limit > 0 && siblings.length >= limit ) { + return; + } + + var $new_element = $( '.fmjs-proto.fm-' + el_name, $element.closest( '.fm-wrapper' ) ).first().clone(); + + $new_element.removeClass( 'fmjs-proto' ); + $new_element = add_more_position == "bottom" ? $new_element.insertBefore( $element.parent() ) : + $new_element.insertAfter( $element.parent() ) ; + fm_renumber( $element.parents( '.fm-wrapper' ) ); + // Trigger for subclasses to do any post-add event handling for the new element + $element.parent().siblings().last().trigger( 'fm_added_element' ); + init_label_macros(); + init_sortable(); +} + +fm_remove = function( $element ) { + $wrapper = $( this ).parents( '.fm-wrapper' ).first(); + $element.parents( '.fm-item' ).first().remove(); + fm_renumber( $wrapper ); +} + +$( document ).ready( function () { + $( document ).on( 'click', '.fm-add-another', function( e ) { + e.preventDefault(); + fm_add_another( $( this ) ); + } ); + + // Handle remove events + $( document ).on( 'click', '.fmjs-remove', function( e ) { + e.preventDefault(); + fm_remove( $( this ) ); + } ); + + // Handle collapse events + $( document ).on( 'click', '.fmjs-collapsible-handle', function() { + $( this ).parents( '.fm-group' ).first().children( '.fm-group-inner' ).slideToggle( 'fast' ); + fm_renumber( $( this ).parents( '.fm-wrapper' ).first() ); + $( this ).parents( '.fm-group' ).first().trigger( 'fm_collapsible_toggle' ); + $( this ).toggleClass( 'closed' ); + if ( $( this ).hasClass( 'closed' ) ) { + $( this ).attr( 'aria-expanded', 'false' ); + } else { + $( this ).attr( 'aria-expanded', 'true' ); + } + } ); + + $( '.fm-collapsed > .fm-group:not(.fmjs-proto) > .fm-group-inner' ).hide(); + + // Initializes triggers to conditionally hide or show fields + fm.init_display_if = function() { + var val; + var src = $( this ).data( 'display-src' ); + var values = getCompareValues( this ); + // Wrapper divs sometimes receive .fm-element, but don't use them as + // triggers. Also don't use autocomplete inputs as triggers, because the + // value is in their sibling hidden fields (which this still matches). + var trigger = $( this ).siblings( '.fm-' + src + '-wrapper' ).find( '.fm-element' ).not( 'div, .fm-autocomplete' ); + + // Sanity check before calling `val()` or `split()`. + if ( 0 === trigger.length ) { + return; + } + + if ( trigger.is( ':checkbox' ) ) { + if ( trigger.is( ':checked' ) ) { + // If checked, use the checkbox value. + val = trigger.val(); + } else { + // Otherwise, use the hidden sibling field with the "unchecked" value. + val = trigger.siblings( 'input[type=hidden][name="' + trigger.attr( 'name' ) + '"]' ).val(); + } + } else if ( trigger.is( ':radio' ) ) { + if ( trigger.filter( ':checked' ).length ) { + val = trigger.filter( ':checked' ).val(); + } else { + // On load, there might not be any selected radio, in which case call the value blank. + val = ''; + } + } else { + val = trigger.val().split( ',' ); + } + trigger.addClass( 'display-trigger' ); + if ( ! match_value( values, val ) ) { + $( this ).hide(); + } + }; + $( '.display-if' ).each( fm.init_display_if ); + + // Controls the trigger to show or hide fields + fm.trigger_display_if = function() { + var val; + var $this = $( this ); + var name = $this.attr( 'name' ); + if ( $this.is( ':checkbox' ) ) { + if ( $this.is( ':checked' ) ) { + val = $this.val(); + } else { + val = $this.siblings( 'input[type=hidden][name="' + name + '"]' ).val(); + } + } else if ( $this.is( ':radio' ) ) { + val = $this.filter( ':checked' ).val(); + } else { + val = $this.val().split( ',' ); + } + $( this ).closest( '.fm-wrapper' ).siblings().each( function() { + if ( $( this ).hasClass( 'display-if' ) ) { + if ( name && name.match( $( this ).data( 'display-src' ) ) != null ) { + if ( match_value( getCompareValues( this ), val ) ) { + $( this ).show(); + } else { + $( this ).hide(); + } + $( this ).trigger( 'fm_displayif_toggle' ); + } + } + } ); + }; + $( document ).on( 'change', '.display-trigger', fm.trigger_display_if ); + + init_label_macros(); + init_sortable(); + + $( document ).on( 'fm_activate_tab fm_customize_control_section_expanded', init_sortable ); + $( document ).on( 'fm_customize_control_section_expanded', init_label_macros ); + $( document ).on( 'fm_customize_control_section_expanded', function () { + $( '.display-if' ).each( fm.init_display_if ); + } ); +} ); + +} )( jQuery ); diff --git a/js/jquery-serializejson/jquery.serializejson.js b/js/jquery-serializejson/jquery.serializejson.js new file mode 100644 index 0000000..9cfe797 --- /dev/null +++ b/js/jquery-serializejson/jquery.serializejson.js @@ -0,0 +1,277 @@ +/*! + SerializeJSON jQuery plugin. + https://github.com/marioizquierdo/jquery.serializeJSON + version 2.6.2 (May, 2015) + + Copyright (c) 2012, 2015 Mario Izquierdo + Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) + and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses. +*/ +(function (factory) { + if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof exports === 'object') { // Node/CommonJS + var jQuery = require('jquery'); + module.exports = factory(jQuery); + } else { // Browser globals (zepto supported) + factory(window.jQuery || window.Zepto || window.$); // Zepto supported on browsers as well + } + +}(function ($) { + "use strict"; + + // jQuery('form').serializeJSON() + $.fn.serializeJSON = function (options) { + var serializedObject, formAsArray, keys, type, value, _ref, f, opts; + f = $.serializeJSON; + opts = f.setupOpts(options); // calculate values for options {parseNumbers, parseBoolens, parseNulls} + formAsArray = this.serializeArray(); // array of objects {name, value} + f.readCheckboxUncheckedValues(formAsArray, this, opts); // add {name, value} of unchecked checkboxes if needed + + serializedObject = {}; + $.each(formAsArray, function (i, input) { + keys = f.splitInputNameIntoKeysArray(input.name, opts); + type = keys.pop(); // the last element is always the type ("string" by default) + if (type !== 'skip') { // easy way to skip a value + value = f.parseValue(input.value, type, opts); // string, number, boolean or null + if (opts.parseWithFunction && type === '_') { // allow for custom parsing + value = opts.parseWithFunction(value, input.name); + } + f.deepSet(serializedObject, keys, value, opts); + } + }); + return serializedObject; + }; + + // Use $.serializeJSON as namespace for the auxiliar functions + // and to define defaults + $.serializeJSON = { + + defaultOptions: { + checkboxUncheckedValue: undefined, // to include that value for unchecked checkboxes (instead of ignoring them) + + parseNumbers: false, // convert values like "1", "-2.33" to 1, -2.33 + parseBooleans: false, // convert "true", "false" to true, false + parseNulls: false, // convert "null" to null + parseAll: false, // all of the above + parseWithFunction: null, // to use custom parser, a function like: function(val){ return parsed_val; } + + customTypes: {}, // override defaultTypes + defaultTypes: { + "string": function(str) { return String(str); }, + "number": function(str) { return Number(str); }, + "boolean": function(str) { var falses = ["false", "null", "undefined", "", "0"]; return falses.indexOf(str) === -1; }, + "null": function(str) { var falses = ["false", "null", "undefined", "", "0"]; return falses.indexOf(str) === -1 ? str : null; }, + "array": function(str) { return JSON.parse(str); }, + "object": function(str) { return JSON.parse(str); }, + "auto": function(str) { return $.serializeJSON.parseValue(str, null, {parseNumbers: true, parseBooleans: true, parseNulls: true}); } // try again with something like "parseAll" + }, + + useIntKeysAsArrayIndex: false // name="foo[2]" value="v" => {foo: [null, null, "v"]}, instead of {foo: ["2": "v"]} + }, + + // Merge option defaults into the options + setupOpts: function(options) { + var opt, validOpts, defaultOptions, optWithDefault, parseAll, f; + f = $.serializeJSON; + + if (options == null) { options = {}; } // options ||= {} + defaultOptions = f.defaultOptions || {}; // defaultOptions + + // Make sure that the user didn't misspell an option + validOpts = ['checkboxUncheckedValue', 'parseNumbers', 'parseBooleans', 'parseNulls', 'parseAll', 'parseWithFunction', 'customTypes', 'defaultTypes', 'useIntKeysAsArrayIndex']; // re-define because the user may override the defaultOptions + for (opt in options) { + if (validOpts.indexOf(opt) === -1) { + throw new Error("serializeJSON ERROR: invalid option '" + opt + "'. Please use one of " + validOpts.join(', ')); + } + } + + // Helper to get the default value for this option if none is specified by the user + optWithDefault = function(key) { return (options[key] !== false) && (options[key] !== '') && (options[key] || defaultOptions[key]); }; + + // Return computed options (opts to be used in the rest of the script) + parseAll = optWithDefault('parseAll'); + return { + checkboxUncheckedValue: optWithDefault('checkboxUncheckedValue'), + + parseNumbers: parseAll || optWithDefault('parseNumbers'), + parseBooleans: parseAll || optWithDefault('parseBooleans'), + parseNulls: parseAll || optWithDefault('parseNulls'), + parseWithFunction: optWithDefault('parseWithFunction'), + + typeFunctions: $.extend({}, optWithDefault('defaultTypes'), optWithDefault('customTypes')), + + useIntKeysAsArrayIndex: optWithDefault('useIntKeysAsArrayIndex') + }; + }, + + // Given a string, apply the type or the relevant "parse" options, to return the parsed value + parseValue: function(str, type, opts) { + var typeFunction, f; + f = $.serializeJSON; + + // Parse with a type if available + typeFunction = opts.typeFunctions && opts.typeFunctions[type]; + if (typeFunction) { return typeFunction(str); } // use specific type + + // Otherwise, check if there is any auto-parse option enabled and use it. + if (opts.parseNumbers && f.isNumeric(str)) { return Number(str); } // auto: number + if (opts.parseBooleans && (str === "true" || str === "false")) { return str === "true"; } // auto: boolean + if (opts.parseNulls && str == "null") { return null; } // auto: null + + // If none applies, just return the str + return str; + }, + + isObject: function(obj) { return obj === Object(obj); }, // is it an Object? + isUndefined: function(obj) { return obj === void 0; }, // safe check for undefined values + isValidArrayIndex: function(val) { return /^[0-9]+$/.test(String(val)); }, // 1,2,3,4 ... are valid array indexes + isNumeric: function(obj) { return obj - parseFloat(obj) >= 0; }, // taken from jQuery.isNumeric implementation. Not using jQuery.isNumeric to support old jQuery and Zepto versions + + optionKeys: function(obj) { if (Object.keys) { return Object.keys(obj); } else { var key, keys = []; for(key in obj){ keys.push(key); } return keys;} }, // polyfill Object.keys to get option keys in IE<9 + + // Split the input name in programatically readable keys. + // The last element is always the type (default "_"). + // Examples: + // "foo" => ['foo', '_'] + // "foo:string" => ['foo', 'string'] + // "foo:boolean" => ['foo', 'boolean'] + // "[foo]" => ['foo', '_'] + // "foo[inn][bar]" => ['foo', 'inn', 'bar', '_'] + // "foo[inn[bar]]" => ['foo', 'inn', 'bar', '_'] + // "foo[inn][arr][0]" => ['foo', 'inn', 'arr', '0', '_'] + // "arr[][val]" => ['arr', '', 'val', '_'] + // "arr[][val]:null" => ['arr', '', 'val', 'null'] + splitInputNameIntoKeysArray: function(name, opts) { + var keys, nameWithoutType, type, _ref, f; + f = $.serializeJSON; + _ref = f.extractTypeFromInputName(name, opts); nameWithoutType = _ref[0]; type = _ref[1]; + keys = nameWithoutType.split('['); // split string into array + keys = $.map(keys, function (key) { return key.replace(/\]/g, ''); }); // remove closing brackets + if (keys[0] === '') { keys.shift(); } // ensure no opening bracket ("[foo][inn]" should be same as "foo[inn]") + keys.push(type); // add type at the end + return keys; + }, + + // Returns [name-without-type, type] from name. + // "foo" => ["foo", '_'] + // "foo:boolean" => ["foo", 'boolean'] + // "foo[bar]:null" => ["foo[bar]", 'null'] + extractTypeFromInputName: function(name, opts) { + var match, validTypes, f; + if (match = name.match(/(.*):([^:]+)$/)){ + f = $.serializeJSON; + + validTypes = f.optionKeys(opts ? opts.typeFunctions : f.defaultOptions.defaultTypes); + validTypes.push('skip'); // skip is a special type that makes it easy to remove + if (validTypes.indexOf(match[2]) !== -1) { + return [match[1], match[2]]; + } else { + throw new Error("serializeJSON ERROR: Invalid type " + match[2] + " found in input name '" + name + "', please use one of " + validTypes.join(', ')); + } + } else { + return [name, '_']; // no defined type, then use parse options + } + }, + + // Set a value in an object or array, using multiple keys to set in a nested object or array: + // + // deepSet(obj, ['foo'], v) // obj['foo'] = v + // deepSet(obj, ['foo', 'inn'], v) // obj['foo']['inn'] = v // Create the inner obj['foo'] object, if needed + // deepSet(obj, ['foo', 'inn', '123'], v) // obj['foo']['arr']['123'] = v // + // + // deepSet(obj, ['0'], v) // obj['0'] = v + // deepSet(arr, ['0'], v, {useIntKeysAsArrayIndex: true}) // arr[0] = v + // deepSet(arr, [''], v) // arr.push(v) + // deepSet(obj, ['arr', ''], v) // obj['arr'].push(v) + // + // arr = []; + // deepSet(arr, ['', v] // arr => [v] + // deepSet(arr, ['', 'foo'], v) // arr => [v, {foo: v}] + // deepSet(arr, ['', 'bar'], v) // arr => [v, {foo: v, bar: v}] + // deepSet(arr, ['', 'bar'], v) // arr => [v, {foo: v, bar: v}, {bar: v}] + // + deepSet: function (o, keys, value, opts) { + var key, nextKey, tail, lastIdx, lastVal, f; + if (opts == null) { opts = {}; } + f = $.serializeJSON; + if (f.isUndefined(o)) { throw new Error("ArgumentError: param 'o' expected to be an object or array, found undefined"); } + if (!keys || keys.length === 0) { throw new Error("ArgumentError: param 'keys' expected to be an array with least one element"); } + + key = keys[0]; + + // Only one key, then it's not a deepSet, just assign the value. + if (keys.length === 1) { + if (key === '') { + o.push(value); // '' is used to push values into the array (assume o is an array) + } else { + o[key] = value; // other keys can be used as object keys or array indexes + } + + // With more keys is a deepSet. Apply recursively. + } else { + nextKey = keys[1]; + + // '' is used to push values into the array, + // with nextKey, set the value into the same object, in object[nextKey]. + // Covers the case of ['', 'foo'] and ['', 'var'] to push the object {foo, var}, and the case of nested arrays. + if (key === '') { + lastIdx = o.length - 1; // asume o is array + lastVal = o[lastIdx]; + if (f.isObject(lastVal) && (f.isUndefined(lastVal[nextKey]) || keys.length > 2)) { // if nextKey is not present in the last object element, or there are more keys to deep set + key = lastIdx; // then set the new value in the same object element + } else { + key = lastIdx + 1; // otherwise, point to set the next index in the array + } + } + + // '' is used to push values into the array "array[]" + if (nextKey === '') { + if (f.isUndefined(o[key]) || !$.isArray(o[key])) { + o[key] = []; // define (or override) as array to push values + } + } else { + if (opts.useIntKeysAsArrayIndex && f.isValidArrayIndex(nextKey)) { // if 1, 2, 3 ... then use an array, where nextKey is the index + if (f.isUndefined(o[key]) || !$.isArray(o[key])) { + o[key] = []; // define (or override) as array, to insert values using int keys as array indexes + } + } else { // for anything else, use an object, where nextKey is going to be the attribute name + if (f.isUndefined(o[key]) || !f.isObject(o[key])) { + o[key] = {}; // define (or override) as object, to set nested properties + } + } + } + + // Recursively set the inner object + tail = keys.slice(1); + f.deepSet(o[key], tail, value, opts); + } + }, + + // Fill the formAsArray object with values for the unchecked checkbox inputs, + // using the same format as the jquery.serializeArray function. + // The value of the unchecked values is determined from the opts.checkboxUncheckedValue + // and/or the data-unchecked-value attribute of the inputs. + readCheckboxUncheckedValues: function (formAsArray, $form, opts) { + var selector, $uncheckedCheckboxes, $el, dataUncheckedValue, f; + if (opts == null) { opts = {}; } + f = $.serializeJSON; + + selector = 'input[type=checkbox][name]:not(:checked):not([disabled])'; + $uncheckedCheckboxes = $form.find(selector).add($form.filter(selector)); + $uncheckedCheckboxes.each(function (i, el) { + $el = $(el); + dataUncheckedValue = $el.attr('data-unchecked-value'); + if(dataUncheckedValue) { // data-unchecked-value has precedence over option opts.checkboxUncheckedValue + formAsArray.push({name: el.name, value: dataUncheckedValue}); + } else { + if (!f.isUndefined(opts.checkboxUncheckedValue)) { + formAsArray.push({name: el.name, value: opts.checkboxUncheckedValue}); + } + } + }); + } + + }; + +})); diff --git a/js/jquery-serializejson/jquery.serializejson.min.js b/js/jquery-serializejson/jquery.serializejson.min.js new file mode 100644 index 0000000..08e0758 --- /dev/null +++ b/js/jquery-serializejson/jquery.serializejson.min.js @@ -0,0 +1,10 @@ +/*! + SerializeJSON jQuery plugin. + https://github.com/marioizquierdo/jquery.serializeJSON + version 2.6.2 (May, 2015) + + Copyright (c) 2012, 2015 Mario Izquierdo + Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) + and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses. +*/ +!function(e){if("function"==typeof define&&define.amd)define(["jquery"],e);else if("object"==typeof exports){var n=require("jquery");module.exports=e(n)}else e(window.jQuery||window.Zepto||window.$)}(function(e){"use strict";e.fn.serializeJSON=function(n){var r,t,s,i,a,u,o;return u=e.serializeJSON,o=u.setupOpts(n),t=this.serializeArray(),u.readCheckboxUncheckedValues(t,this,o),r={},e.each(t,function(e,n){s=u.splitInputNameIntoKeysArray(n.name,o),i=s.pop(),"skip"!==i&&(a=u.parseValue(n.value,i,o),o.parseWithFunction&&"_"===i&&(a=o.parseWithFunction(a,n.name)),u.deepSet(r,s,a,o))}),r},e.serializeJSON={defaultOptions:{checkboxUncheckedValue:void 0,parseNumbers:!1,parseBooleans:!1,parseNulls:!1,parseAll:!1,parseWithFunction:null,customTypes:{},defaultTypes:{string:function(e){return String(e)},number:function(e){return Number(e)},"boolean":function(e){var n=["false","null","undefined","","0"];return-1===n.indexOf(e)},"null":function(e){var n=["false","null","undefined","","0"];return-1===n.indexOf(e)?e:null},array:function(e){return JSON.parse(e)},object:function(e){return JSON.parse(e)},auto:function(n){return e.serializeJSON.parseValue(n,null,{parseNumbers:!0,parseBooleans:!0,parseNulls:!0})}},useIntKeysAsArrayIndex:!1},setupOpts:function(n){var r,t,s,i,a,u;u=e.serializeJSON,null==n&&(n={}),s=u.defaultOptions||{},t=["checkboxUncheckedValue","parseNumbers","parseBooleans","parseNulls","parseAll","parseWithFunction","customTypes","defaultTypes","useIntKeysAsArrayIndex"];for(r in n)if(-1===t.indexOf(r))throw new Error("serializeJSON ERROR: invalid option '"+r+"'. Please use one of "+t.join(", "));return i=function(e){return n[e]!==!1&&""!==n[e]&&(n[e]||s[e])},a=i("parseAll"),{checkboxUncheckedValue:i("checkboxUncheckedValue"),parseNumbers:a||i("parseNumbers"),parseBooleans:a||i("parseBooleans"),parseNulls:a||i("parseNulls"),parseWithFunction:i("parseWithFunction"),typeFunctions:e.extend({},i("defaultTypes"),i("customTypes")),useIntKeysAsArrayIndex:i("useIntKeysAsArrayIndex")}},parseValue:function(n,r,t){var s,i;return i=e.serializeJSON,s=t.typeFunctions&&t.typeFunctions[r],s?s(n):t.parseNumbers&&i.isNumeric(n)?Number(n):!t.parseBooleans||"true"!==n&&"false"!==n?t.parseNulls&&"null"==n?null:n:"true"===n},isObject:function(e){return e===Object(e)},isUndefined:function(e){return void 0===e},isValidArrayIndex:function(e){return/^[0-9]+$/.test(String(e))},isNumeric:function(e){return e-parseFloat(e)>=0},optionKeys:function(e){if(Object.keys)return Object.keys(e);var n,r=[];for(n in e)r.push(n);return r},splitInputNameIntoKeysArray:function(n,r){var t,s,i,a,u;return u=e.serializeJSON,a=u.extractTypeFromInputName(n,r),s=a[0],i=a[1],t=s.split("["),t=e.map(t,function(e){return e.replace(/\]/g,"")}),""===t[0]&&t.shift(),t.push(i),t},extractTypeFromInputName:function(n,r){var t,s,i;if(t=n.match(/(.*):([^:]+)$/)){if(i=e.serializeJSON,s=i.optionKeys(r?r.typeFunctions:i.defaultOptions.defaultTypes),s.push("skip"),-1!==s.indexOf(t[2]))return[t[1],t[2]];throw new Error("serializeJSON ERROR: Invalid type "+t[2]+" found in input name '"+n+"', please use one of "+s.join(", "))}return[n,"_"]},deepSet:function(n,r,t,s){var i,a,u,o,l,c;if(null==s&&(s={}),c=e.serializeJSON,c.isUndefined(n))throw new Error("ArgumentError: param 'o' expected to be an object or array, found undefined");if(!r||0===r.length)throw new Error("ArgumentError: param 'keys' expected to be an array with least one element");i=r[0],1===r.length?""===i?n.push(t):n[i]=t:(a=r[1],""===i&&(o=n.length-1,l=n[o],i=c.isObject(l)&&(c.isUndefined(l[a])||r.length>2)?o:o+1),""===a?(c.isUndefined(n[i])||!e.isArray(n[i]))&&(n[i]=[]):s.useIntKeysAsArrayIndex&&c.isValidArrayIndex(a)?(c.isUndefined(n[i])||!e.isArray(n[i]))&&(n[i]=[]):(c.isUndefined(n[i])||!c.isObject(n[i]))&&(n[i]={}),u=r.slice(1),c.deepSet(n[i],u,t,s))},readCheckboxUncheckedValues:function(n,r,t){var s,i,a,u,o;null==t&&(t={}),o=e.serializeJSON,s="input[type=checkbox][name]:not(:checked):not([disabled])",i=r.find(s).add(r.filter(s)),i.each(function(r,s){a=e(s),u=a.attr("data-unchecked-value"),u?n.push({name:s.name,value:u}):o.isUndefined(t.checkboxUncheckedValue)||n.push({name:s.name,value:t.checkboxUncheckedValue})})}}}); \ No newline at end of file diff --git a/js/richtext.js b/js/richtext.js new file mode 100644 index 0000000..fe559dc --- /dev/null +++ b/js/richtext.js @@ -0,0 +1,173 @@ +( function( $ ) { + fm.richtextarea = { + add_rte_to_visible_textareas: function() { + $( 'textarea.fm-richtext:visible' ).each( function() { + if ( ! $( this ).hasClass( 'fm-tinymce' ) ) { + var init, ed_id, mce_options, qt_options, proto_id; + $( this ).addClass( 'fm-tinymce' ); + ed_id = $( this ).attr( 'id' ); + + if ( typeof tinymce !== 'undefined' ) { + + if ( typeof tinyMCEPreInit.mceInit[ ed_id ] === 'undefined' ) { + proto_id = $( this ).data( 'proto-id' ); + + // Clean up the proto id which appears in some of the wp_editor generated HTML + $( this ).closest( '.fm-wrapper' ).html( $( this ).closest( '.fm-wrapper' ).html().replace( new RegExp( proto_id, 'g' ), ed_id ) ); + + // This needs to be initialized, so we need to get the options from the proto + if ( proto_id && typeof tinyMCEPreInit.mceInit[ proto_id ] !== 'undefined' ) { + mce_options = $.extend( true, {}, tinyMCEPreInit.mceInit[ proto_id ] ); + mce_options.body_class = mce_options.body_class.replace( proto_id, ed_id ); + mce_options.selector = mce_options.selector.replace( proto_id, ed_id ); + mce_options.wp_skip_init = false; + tinyMCEPreInit.mceInit[ ed_id ] = mce_options; + } else { + // TODO: No data to work with, this should throw some sort of error + return; + } + + if ( proto_id && typeof tinyMCEPreInit.qtInit[ proto_id ] !== 'undefined' ) { + qt_options = $.extend( true, {}, tinyMCEPreInit.qtInit[ proto_id ] ); + qt_options.id = qt_options.id.replace( proto_id, ed_id ); + tinyMCEPreInit.qtInit[ ed_id ] = qt_options; + } + } + + try { + if ( 'html' !== fm.richtextarea.mode_enabled( this ) ) { + tinyMCEPreInit.mceInit[ ed_id ].setup = function ( ed ) { + ed.on( 'init', function( args ) { + /** + * Event after TinyMCE loads for an Fieldmanager_RichTextArea. + * + * @var {Object} TinyMCE instance. + */ + $( document ).trigger( 'fm_richtext_init', ed ); + }); + }; + tinymce.init( tinyMCEPreInit.mceInit[ ed_id ] ); + $( this ).closest( '.wp-editor-wrap' ).on( 'click.wp-editor', function() { + if ( this.id ) { + window.wpActiveEditor = this.id.slice( 3, -5 ); + } + } ); + } + } catch(e){} + + try { + if ( typeof tinyMCEPreInit.qtInit[ ed_id ] !== 'undefined' ) { + quicktags( tinyMCEPreInit.qtInit[ ed_id ] ); + // _buttonsInit() only needs to be called on dynamic editors + // quicktags() handles it for us on the first initialization + if ( typeof QTags !== 'undefined' && -1 !== ed_id.indexOf( '-dynamic-' ) ) { + QTags._buttonsInit(); + } + } + } catch(e){}; + } + } + } ); + }, + + reload_editors: function( e, wrap ) { + if ( ! wrap || 'undefined' === typeof wrap.nodeType ) { + return; + } + + $( '.fm-tinymce', wrap ).each( function() { + var html_mode = ( 'html' === fm.richtextarea.mode_enabled( this ) ) + , ed = tinymce.get( this.id ) + , content = ed.getContent() + , cmd; + + if ( html_mode ) { + $( '#' + this.id + '-tmce' ).click(); + } + + // Disable the editor + cmd = 'mceRemoveControl'; + if ( parseInt( tinymce.majorVersion ) >= 4 ) { + cmd = 'mceRemoveEditor'; + } + tinymce.execCommand( cmd, false, this.id ); + + // Immediately re-enable the editor + cmd = 'mceAddControl'; + if ( parseInt( tinymce.majorVersion ) >= 4 ) { + cmd = 'mceAddEditor'; + } + tinymce.execCommand( cmd, false, this.id ); + + // Replace the content with what it was to correct paragraphs + ed = tinymce.get( this.id ); + ed.setContent( content ); + + if ( html_mode ) { + $( '#' + this.id + '-html' ).click(); + } + }); + }, + + mode_enabled: function( el ) { + return $( el ).closest( '.html-active' ).length ? 'html' : 'tinymce'; + }, + + /** + * Ensure that the main editor's state remains unaffected by any FM editors + */ + reset_core_editor_mode: function() { + if ( 'html' === core_editor_state || 'tinymce' === core_editor_state ) { + setUserSetting( 'editor', core_editor_state ); + } + } + } + $( document ).on( 'fm_collapsible_toggle fm_added_element fm_activate_tab fm_displayif_toggle', fm.richtextarea.add_rte_to_visible_textareas ); + $( document ).on( 'fm_sortable_drop', fm.richtextarea.reload_editors ); + + $( document ).on( 'click', '.fm-richtext .wp-switch-editor', function() { + var aid = this.id, + l = aid.length, + id = aid.substr( 0, l - 5 ), + mode = 'html' === aid.substr( l - 4 ) ? 'html' : 'tinymce'; + + // This only runs if the default editor is set to 'cookie' + if ( 'fm-edit-dynamic' !== id.substr( 0, 15 ) && $( this ).closest( '.fm-richtext-remember-editor' ).length ) { + setUserSetting( 'editor_' + id.replace( /-/g, '_' ).replace( /[^a-z0-9_]/ig, '' ), mode ); + } + + // Reset the core editor's state so it remains unaffected by this event. + // We delay by 50ms to ensure that this event has enough time to run. + // WordPress won't change the state of the editor until the end of the + // event delegation. + setTimeout( fm.richtextarea.reset_core_editor_mode, 50 ); + } ); + + var core_editor_state; + if ( typeof getUserSetting === 'function' ) { + core_editor_state = getUserSetting( 'editor' ); + } + + /** + * If the main editor's state changes, note that change. + */ + $( document ).on( 'click', '#content-tmce,#content-html', function() { + var aid = this.id, + l = aid.length, + id = aid.substr( 0, l - 5 ), + mode = 'html' === aid.substr( l - 4 ) ? 'html' : 'tinymce'; + + core_editor_state = mode; + } ); + + /** + * On document.load, init the editors and make the global meta box drag-drop + * event reload the editors. + */ + $( function() { + fm.richtextarea.add_rte_to_visible_textareas(); + $( '.meta-box-sortables' ).on( 'sortstop', function( e, obj ) { + fm.richtextarea.reload_editors( e, obj.item[0] ); + } ); + } ); +} ) ( jQuery ); \ No newline at end of file diff --git a/languages/fieldmanager-beta-customize.pot b/languages/fieldmanager-beta-customize.pot new file mode 100644 index 0000000..42c9003 --- /dev/null +++ b/languages/fieldmanager-beta-customize.pot @@ -0,0 +1,78 @@ +# Copyright (C) 2016 David Herrera, Alley Interactive +# This file is distributed under the same license as the Fieldmanager Beta: Customize package. +msgid "" +msgstr "" +"Project-Id-Version: Fieldmanager Beta: Customize 0.1.0\n" +"Report-Msgid-Bugs-To: " +"https://wordpress.org/support/plugin/fieldmanager-beta-customize\n" +"POT-Creation-Date: 2016-10-12 02:04:05+00:00\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: 2016-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"X-Generator: grunt-wp-i18n 0.5.4\n" +"X-Poedit-KeywordsList: " +"__;_e;_x:1,2c;_ex:1,2c;_n:1,2;_nx:1,2,4c;_n_noop:1,2;_nx_noop:1,2,3c;esc_" +"attr__;esc_html__;esc_attr_e;esc_html_e;esc_attr_x:1,2c;esc_html_x:1,2c;\n" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-Country: United States\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-Basepath: ../\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-Bookmarks: \n" +"X-Textdomain-Support: yes\n" + +#: php/context/class-fieldmanager-beta-context-customize.php:90 +msgid "Invalid value." +msgstr "" + +#: php/customize/class-fieldmanager-beta-customize-control.php:41 +msgid "" +"Fieldmanager_Beta_Customize_Control requires a " +"Fieldmanager_Beta_Context_Customize" +msgstr "" + +#: php/customize/class-fieldmanager-beta-customize-setting.php:43 +msgid "" +"Fieldmanager_Beta_Customize_Setting requires a " +"Fieldmanager_Beta_Context_Customize" +msgstr "" + +#: php/demo/class-fieldmanager-beta-customize-demo.php:87 +msgid "Twitter Handle" +msgstr "" + +#: php/demo/class-fieldmanager-beta-customize-demo.php:88 +msgid "Facebook URL" +msgstr "" + +#: php/demo/class-fieldmanager-beta-customize-demo.php:89 +msgid "Instagram Handle" +msgstr "" + +#: php/demo/class-fieldmanager-beta-customize-demo.php:90 +msgid "YouTube URL" +msgstr "" + +#. Plugin Name of the plugin/theme +msgid "Fieldmanager Beta: Customize" +msgstr "" + +#. Plugin URI of the plugin/theme +msgid "https://www.github.com/dlh01/fieldmanager-beta-customize-context" +msgstr "" + +#. Description of the plugin/theme +msgid "Add Fieldmanager fields to the Customizer." +msgstr "" + +#. Author of the plugin/theme +msgid "David Herrera, Alley Interactive" +msgstr "" + +#. Author URI of the plugin/theme +msgid "https://www.alleyinteractive.com" +msgstr "" \ No newline at end of file diff --git a/package.json b/package.json new file mode 100755 index 0000000..cbd1187 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "fieldmanager-beta-customize", + "version": "0.1.0", + "main": "Gruntfile.js", + "contributors": [ + "David Herrera", + "Alley Interactive" + ], + "devDependencies": { + "grunt": "~0.4.5", + "grunt-wp-i18n": "~0.5.0", + "grunt-wp-readme-to-markdown": "~2.0.0" + } +} diff --git a/php/context/class-fieldmanager-beta-context-customize.php b/php/context/class-fieldmanager-beta-context-customize.php new file mode 100644 index 0000000..e72684b --- /dev/null +++ b/php/context/class-fieldmanager-beta-context-customize.php @@ -0,0 +1,284 @@ +args = wp_parse_args( $args, array( + 'section_args' => false, + 'setting_args' => array(), + 'control_args' => array(), + ) ); + + $this->fm = $fm; + + add_action( 'customize_register', array( $this, 'customize_register' ), 100 ); + } + + /** + * Fires once WordPress has loaded in the Customizer. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + */ + public function customize_register( $manager ) { + $this->register( $manager ); + } + + /** + * Exposes Fieldmanager_Context::render_field() for the control to call. + * + * @param array $args Unused. + */ + public function render_field( $args = array() ) { + return parent::render_field( $args ); + } + + /** + * Filter the validity of a Customize setting value. + * + * Amend the `$validity` object via its `WP_Error::add()` method. + * + * @see WP_Customize_Setting::validate(). + * + * @param WP_Error $validity Filtered from `true` to `WP_Error` when invalid. + * @param mixed $value Value of the setting. + * @param WP_Customize_Setting $setting WP_Customize_Setting instance. + */ + public function validate_callback( $validity, $value, $setting ) { + $value = $this->parse_field_query_string( $value ); + + // Start assuming calls to wp_die() signal Fieldmanager validation errors. + $this->start_handling_wp_die(); + + try { + $this->prepare_data( $setting->value(), $value ); + } catch ( Exception $e ) { + if ( ! is_wp_error( $validity ) ) { + $validity = new WP_Error(); + } + + /* + * Handle all exceptions Fieldmanager might generate, but use the + * message from only validation exceptions, which are more + * user-friendly. For others, use the generic message from + * WP_Customize_Setting::validate(). + */ + $message = ( $e instanceof FM_Validation_Exception ) ? $e->getMessage() : __( 'Invalid value.', 'fieldmanager-beta-customize' ); + + // @see https://core.trac.wordpress.org/ticket/37890 for the use of array( $value ). + $validity->add( 'fieldmanager', $message, array( $value ) ); + } + + // Resume normal wp_die() handling. + $this->stop_handling_wp_die(); + + return $validity; + } + + /** + * Filter a Customize setting value in un-slashed form. + * + * @param mixed $value Setting value. + * @param WP_Customize_Setting $setting WP_Customize_Setting instance. + * @return mixed The sanitized setting value. + */ + public function sanitize_callback( $value, $setting ) { + $value = $this->parse_field_query_string( $value ); + + // Run the validation routine in case we need to reject the value. + $validity = $this->validate_callback( true, $value, $setting ); + + if ( is_wp_error( $validity ) ) { + /* + * The 'customize_save_validation_before' action was added with the + * Customizer's validation framework. If it fires, assume it's safe + * to return a WP_Error to indicate invalid values. Returning null + * is a backwards-compatible way to reject a value from + * WP_Customize_Setting::sanitize(). See + * https://core.trac.wordpress.org/ticket/34893. + */ + return ( did_action( 'customize_save_validation_before' ) ) ? $validity : null; + } + + // Return the value after Fieldmanager takes a shot at it. + return stripslashes_deep( $this->prepare_data( $setting->value(), $value ) ); + } + + /** + * Filter the callback for killing WordPress execution. + * + * Fieldmanager calls wp_die() to signal some errors, but messages passed to + * wp_die() are not automatically displayed in the Customizer. This filter + * should return a callback that throws the message passed to wp_die() as an + * exception, which the default validation callback in this context can + * catch and convert to a WP_Error. + * + * @return callable Callback function name. + */ + public function on_filter_wp_die_handler() { + /* + * Side effect: We don't want execution to stop, so remove all other + * filters because they presumably assume the opposite. See, e.g., + * WP_Customize_Manager::remove_preview_signature(). + */ + remove_all_filters( current_filter() ); + + // Return the new callback. + return array( $this, 'wp_die_handler' ); + } + + /** + * Handle wp_die() by throwing an exception instead of killing execution. + * + * @throws FM_Validation_Exception With the message passed to wp_die(). + * + * @param string|WP_Error $message Error message or WP_Error object. + * @param string $title Optional. Error title. + * @param string|array $args Optional. Arguments to control behavior. + */ + public function wp_die_handler( $message, $title, $args ) { + if ( is_wp_error( $message ) ) { + $message = $message->get_error_message(); + } + + /* + * Modify $message in two ways that follow from our assumption that + * Fieldmanager generated this wp_die(): Remove the blank lines and + * "back button" message, and unescape HTML. + */ + throw new FM_Validation_Exception( preg_replace( '#\n\n.*?$#', '', htmlspecialchars_decode( $message, ENT_QUOTES ) ) ); + } + + /** + * Create a Customizer section, setting, and control for this field. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + */ + protected function register( $manager ) { + $this->register_section( $manager ); + $this->register_setting( $manager ); + $this->register_control( $manager ); + } + + /** + * Add a Customizer section for this field. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + * @return WP_Customize_Section|void Section object, where supported, if created. + */ + protected function register_section( $manager ) { + if ( false === $this->args['section_args'] ) { + return; + } + + return $manager->add_section( $this->fm->name, $this->args['section_args'] ); + } + + /** + * Add a Customizer setting for this field. + * + * By default, Fieldmanager registers one setting for a group and sends all + * of the group values from the Customizer, rather than individual settings + * for its children, so sanitization and validation routines can access the + * full group data. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + * @return Fieldmanager_Beta_Customize_Setting Setting object, where supported. + */ + protected function register_setting( $manager ) { + return $manager->add_setting( + new Fieldmanager_Beta_Customize_Setting( $manager, $this->fm->name, wp_parse_args( + $this->args['setting_args'], + array( + 'context' => $this, + ) + ) ) + ); + } + + /** + * Add a Customizer control for this field. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + * @return Fieldmanager_Beta_Customize_Control Control object, where supported. + */ + protected function register_control( $manager ) { + return $manager->add_control( + new Fieldmanager_Beta_Customize_Control( $manager, $this->fm->name, wp_parse_args( + $this->args['control_args'], + array( + 'section' => $this->fm->name, + 'context' => $this, + ) + ) ) + ); + } + + /** + * Decode form element values for this field from a URL-encoded string. + * + * @param mixed $value Value to parse. + * @return mixed + */ + protected function parse_field_query_string( $value ) { + if ( is_string( $value ) && 0 === strpos( $value, $this->fm->name ) ) { + // Parse the query-string version of our values into an array. + parse_str( $value, $value ); + } + + if ( is_array( $value ) && array_key_exists( $this->fm->name, $value ) ) { + // If the option name is the top-level array key, get just the value. + $value = $value[ $this->fm->name ]; + } + + return $value; + } + + /** + * Add filters that convert calls to wp_die() into exceptions. + * + * @return bool Whether the filters were added. + */ + protected function start_handling_wp_die() { + return ( + add_filter( 'wp_die_ajax_handler', array( $this, 'on_filter_wp_die_handler' ), 0 ) + && add_filter( 'wp_die_handler', array( $this, 'on_filter_wp_die_handler' ), 0 ) + ); + } + + /** + * Remove filters that convert calls to wp_die() into exceptions. + * + * @return bool Whether the filters were removed. + */ + protected function stop_handling_wp_die() { + return ( + remove_filter( 'wp_die_ajax_handler', array( $this, 'on_filter_wp_die_handler' ), 0 ) + && remove_filter( 'wp_die_handler', array( $this, 'on_filter_wp_die_handler' ), 0 ) + ); + } +} diff --git a/php/customize/class-fieldmanager-beta-customize-control.php b/php/customize/class-fieldmanager-beta-customize-control.php new file mode 100644 index 0000000..1e247c0 --- /dev/null +++ b/php/customize/class-fieldmanager-beta-customize-control.php @@ -0,0 +1,91 @@ +context instanceof Fieldmanager_Beta_Context_Customize ) && FM_DEBUG ) { + throw new FM_Developer_Exception( + __( 'Fieldmanager_Beta_Customize_Control requires a Fieldmanager_Beta_Context_Customize', 'fieldmanager-beta-customize' ) + ); + } + } + + /** + * Enqueue control-related scripts and styles. + */ + public function enqueue() { + wp_register_script( + 'fm-serializejson', + FM_BETA_CUSTOMIZE_URL . 'js/jquery-serializejson/jquery.serializejson.min.js', + array(), + '2.0.0', + true + ); + + fm_add_script( + 'fm-customize', + 'js/fieldmanager-customize.js', + array( 'jquery', 'underscore', 'editor', 'quicktags', 'fieldmanager_script', 'customize-controls', 'fm-serializejson' ), + FM_BETA_CUSTOMIZE_VERSION, + true, + '', + array(), + FM_BETA_CUSTOMIZE_URL + ); + } + + /** + * Render the control's content. + * + * @see Fieldmanager_Context::render_field(). + * @see WP_Customize_Control::render_content(). + */ + protected function render_content() { + ?> + + label ) ) : ?> + label ); ?> + + + description ) ) : ?> + description ); ?> + + + context->render_field( array( 'data' => $this->value() ) ); + } + } +endif; diff --git a/php/customize/class-fieldmanager-beta-customize-setting.php b/php/customize/class-fieldmanager-beta-customize-setting.php new file mode 100644 index 0000000..aab7d7a --- /dev/null +++ b/php/customize/class-fieldmanager-beta-customize-setting.php @@ -0,0 +1,74 @@ +context = $args['context']; + + // Set the default without checking isset() (as in WP_Customize_Setting) to support null values. + $this->default = $this->context->fm->default_value; + + // Validate and sanitize with the context. + $this->validate_callback = array( $this->context, 'validate_callback' ); + $this->sanitize_callback = array( $this->context, 'sanitize_callback' ); + + // Use the Fieldmanager submenu default. + $this->type = 'option'; + } elseif ( FM_DEBUG ) { + throw new FM_Developer_Exception( __( 'Fieldmanager_Beta_Customize_Setting requires a Fieldmanager_Beta_Context_Customize', 'fieldmanager-beta-customize' ) ); + } + + parent::__construct( $manager, $id, $args ); + } + + /** + * Filter non-multidimensional theme mods and options. + * + * Settings created with the Customizer context are non-multidimensional + * by default. If you create your own multidimensional settings, you + * might need to extend _multidimensional_preview_filter() accordingly. + * + * @param mixed $original Old value. + * @return mixed New or old value. + */ + public function _preview_filter( $original ) { + /* + * Don't continue to the parent _preview_filter() while sanitizing + * or validating. _preview_filter() eventually calls + * sanitize_callback() and validate_callback(), which calls the + * hooks to those methods in Fieldmanager_Beta_Context_Customize, which + * calls WP_Customize_Setting::value(), which ends up back here. + */ + if ( doing_filter( "customize_sanitize_{$this->id}" ) || doing_filter( "customize_validate_{$this->id}" ) ) { + return $original; + } + + return parent::_preview_filter( $original ); + } + } +endif; diff --git a/php/demo/class-fieldmanager-beta-customize-demo.php b/php/demo/class-fieldmanager-beta-customize-demo.php new file mode 100644 index 0000000..995ba6f --- /dev/null +++ b/php/demo/class-fieldmanager-beta-customize-demo.php @@ -0,0 +1,210 @@ +setup(); + } + return self::$instance; + } + + /** + * Set up. + */ + public function setup() { + add_action( 'fm_customize', array( $this, 'customizer_init' ), 1000 ); + } + + /** + * Initialize demos. + */ + public function customizer_init() { + $fm = new Fieldmanager_Textfield( array( 'name' => 'basic_text' ) ); + fm_beta_customize_add_to_customizer( array( + 'section_args' => array( + 'priority' => 10, + 'title' => 'Fieldmanager Text Field', + ), + ), $fm ); + + $fm = new Fieldmanager_Group( array( + 'name' => 'option_fields', + 'children' => array( + 'text' => new Fieldmanager_Textfield( 'Text Field' ), + 'autocomplete' => new Fieldmanager_Autocomplete( 'Autocomplete', array( 'datasource' => new Fieldmanager_Datasource_Post() ) ), + 'local_data' => new Fieldmanager_Autocomplete( 'Autocomplete without ajax', array( + 'datasource' => new Fieldmanager_Datasource( array( + 'options' => array( 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ) + ) ), + ) ), + 'textarea' => new Fieldmanager_TextArea( 'TextArea' ), + 'media' => new Fieldmanager_Media( 'Media File' ), + 'checkbox' => new Fieldmanager_Checkbox( 'Checkbox' ), + 'radios' => new Fieldmanager_Radios( 'Radio Buttons', array( 'options' => array( 'One', 'Two', 'Three' ) ) ), + 'select' => new Fieldmanager_Select( 'Select Dropdown', array( 'options' => array( 'One', 'Two', 'Three' ) ) ), + 'richtextarea' => new Fieldmanager_RichTextArea( 'Rich Text Area' ), + ) + ) ); + fm_beta_customize_add_to_customizer( array( + 'section_args' => array( + 'capability' => 'edit_posts', + 'description' => 'A Fieldmanager demo section', + 'priority' => 15, + 'title' => 'Fieldmanager Group', + ), + 'setting_args' => array( + 'type' => 'theme_mod', + 'transport' => 'postMessage', + ), + ), $fm ); + + add_action( 'wp_footer', array( $this, 'wp_footer' ), 100 ); + + $fm = new Fieldmanager_Group( array( + 'name' => 'contact_methods', + 'children' => array( + 'twitter_handle' => new Fieldmanager_TextField( __( 'Twitter Handle', 'fieldmanager-beta-customize' ) ), + 'facebook_url' => new Fieldmanager_Link( __( 'Facebook URL', 'fieldmanager-beta-customize' ) ), + 'instagram_handle' => new Fieldmanager_TextField( __( 'Instagram Handle', 'fieldmanager-beta-customize' ) ), + 'youtube_url' => new Fieldmanager_Link( __( 'YouTube URL', 'fieldmanager-beta-customize' ) ), + ), + ) ); + fm_beta_customize_add_to_customizer( 'Contact Methods', $fm ); + + $fm = new Fieldmanager_Group( array( + 'name' => 'repeatable_text', + 'description' => 'Psst... There is also a hidden field in this meta box with a set value.', + 'children' => array( + 'password_field' => new Fieldmanager_Password( 'Password Field' ), + 'hidden_field' => new Fieldmanager_Hidden( 'Hidden Field', array( 'default_value' => 'Fieldmanager was here' ) ), + 'link_field' => new Fieldmanager_Link( 'Link Field', array( 'description' => 'This is a text field that sanitizes the value as a URL' ) ), + 'date_field' => new Fieldmanager_Datepicker( 'Datepicker Field' ), + 'color_field' => new Fieldmanager_Colorpicker( 'Colorpicker Field' ), + 'date_customized_field' => new Fieldmanager_Datepicker( array( + 'label' => 'Datepicker Field with Options', + 'date_format' => 'Y-m-d', + 'use_time' => true, + 'js_opts' => array( + 'dateFormat' => 'yy-mm-dd', + 'changeMonth' => true, + 'changeYear' => true, + 'minDate' => '2010-01-01', + 'maxDate' => '2015-12-31' + ) + ) ), + ) + ) ); + fm_beta_customize_add_to_customizer( array( + 'section_args' => array( + 'title' => 'Fieldmanager Miscellaneous Fields', + 'panel' => 'fm_demos', + ), + 'control_args' => array( + 'section' => 'title_tagline', + 'priority' => 200, + ), + ), $fm ); + + $fm = new Fieldmanager_TextField( 'Field Requiring Numeric Values', array( + 'name' => 'validated_text', + 'description' => 'Try typing "Cowbell" and saving your changes.', + 'validate' => array( 'is_numeric' ), + 'sanitize' => 'intval', + ) ); + fm_beta_customize_add_to_customizer( 'Fieldmanager Validated Fields', $fm ); + } + + /** + * Display the value of some demo fields in the Customizer preview. + */ + public function wp_footer() { + if ( ! is_customize_preview() ) { + return; + } + + $option_fields = get_theme_mod( 'option_fields' ); + ?> +
+

Greetings from the Fieldmanager Customizer demos.

+

The values you see below are controlled by "Fieldmanager Text Field" and "Fieldmanager Group" sections in the Customizer. Try changing them to see the results.

+
    +
  • Text Field (using "refresh" transport):
  • +
  • Group (using "postMessage" transport): +
      +
    • Text Field:
    • +
    • Autocomplete:
    • +
    • Autocomplete without ajax:
    • +
    • TextArea:
    • +
    • Media File:
    • +
    • Checkbox:
    • +
    • Radio Buttons:
    • +
    • Select Dropdown:
    • +
    • Rich Text Area:
    • +
    +
  • +
+
+ + editor_settings['teeny'] ) ) { + $this->editor_settings['teeny'] = true; + } + + return parent::form_element( $value ); + } + + /** + * Add necessary filters before generating the editor. + */ + protected function add_editor_filters() { + parent::add_editor_filters(); + + if ( ! self::$has_registered_customize_scripts ) { + // This action must fire after settings are exported in WP_Customize_Manager::customize_pane_settings(). + add_action( 'customize_controls_print_footer_scripts', array( $this, 'customize_controls_print_footer_scripts' ), 1001 ); + self::$has_registered_customize_scripts = true; + } + } + + /** + * Print Customizer control scripts in the footer. + */ + public function customize_controls_print_footer_scripts() { + if ( class_exists( '_WP_Editors' ) ) { + if ( false === has_action( 'customize_controls_print_footer_scripts', array( '_WP_Editors', 'editor_js' ) ) ) { + // Print the necessary JS for an RTE, unless we can't or suspect it's already there. + _WP_Editors::editor_js(); + _WP_Editors::enqueue_scripts(); + } + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..44f0fdb --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + ./tests/ + + + diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..77b2764 --- /dev/null +++ b/readme.txt @@ -0,0 +1,44 @@ +=== Fieldmanager Beta: Customize === +Contributors: dlh, alleyinteractive +Requires at least: 4.4 +Tested up to: 4.6.1 +Stable tag: 0.1.0 +License: GPLv2 or later +License URI: http://www.gnu.org/licenses/gpl-2.0.html + +A Fieldmanager Beta plugin for the Customize Context. + +== Description == + +This is the proposed Customize context for Fieldmanager. You can install the plugin alongside a stable Fieldmanager release to help test and refine the context. + +The official Pull Request for the Customize context, plus tests, is [on GitHub](https://github.com/alleyinteractive/wordpress-fieldmanager/pull/399). + +== Installation == + +1. Install and activate [Fieldmanager](https://github.com/alleyinteractive/wordpress-fieldmanager). +2. Install and activate this plugin. +3. Use the `fm_customize` context action to instantiate your fields. For example: + + add_action( 'fm_customize', function () { + $fm = new Fieldmanager_TextField( 'My Field', [ 'name' => 'foo' ] ); + fm_beta_customize_add_to_customizer( 'My Section', $fm ); + } ); + +For more code examples, browse `php/demos/class-fieldmanager-beta-customize-demo.php`. To see the demos in action in the Customizer, place `add_action( 'fm_customize', 'fm_beta_customize_demo' )` in your plugin or theme. + +== Screenshots == + +1. Fieldmanager mingling with other sections in the Customizer. +2. Fieldmanager fields in the Customizer. +3. Detail from the demos bundled with this plugin. + +== Changelog == + += 0.1.0 = +* Initial release. + +== Fieldmanager-specific quirks == + +* RichTextAreas: These are supported via the `Fieldmanager_Beta_Customize_RichTextArea` class included with this plugin. Use a `Fieldmanager_Beta_Customize_RichTextArea` in place of `Fieldmanager_RichTextArea` when you want to use TinyMCE in the Customizer. +* Scripts and styles: Some Fieldmanager JavaScript and CSS files require changes for the Customize context. This plugin includes the updated versions of those files and filters Fieldmanager to return them. diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100755 index 0000000..9de5d72 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,25 @@ +