3task Toolbar > Toolbar. * * The extensions come unchanged from TinyMCE 4.9.11, the version WordPress ships (LGPL-2.1, see * assets/tinymce/LICENSE-tinymce.txt). They are not loaded while Advanced Editor Tools is active, which brings the same ones. * * @package ThreeCE */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Toolbar. */ class ThreeCE_Toolbar { /** * TinyMCE extensions shipped in assets/tinymce/plugins. */ const EXTENSIONS = array( 'table', 'advlist', 'searchreplace', 'anchor', 'code', 'visualblocks', 'visualchars', 'nonbreaking', 'insertdatetime' ); /** * Buttons that need one of our extensions or come from this plugin; only for users who write posts. */ const OWN_BUTTONS = array( 'table', 'searchreplace', 'anchor', 'code', 'visualblocks', 'visualchars', 'nonbreaking', 'insertdatetime', 'threece_elements', 'threece_emoji', 'threece_table_style' ); const OPTION = 'threece_toolbar'; const ROWS = 4; const ROW_SIZE = 40; /** * Hooks. */ public static function init(): void { add_filter( 'mce_external_plugins', array( __CLASS__, 'extensions' ), 20 ); add_action( 'init', array( __CLASS__, 'register_setting' ) ); add_filter( 'mce_buttons', array( __CLASS__, 'row_1' ), 30, 2 ); add_filter( 'mce_buttons_2', array( __CLASS__, 'row_2' ), 30, 2 ); add_filter( 'mce_buttons_3', array( __CLASS__, 'row_3' ), 30, 2 ); add_filter( 'mce_buttons_4', array( __CLASS__, 'row_4' ), 30, 2 ); } /** * Editors that get our toolbar: the post editor and the Classic block. Every other wp_editor() on the site * (meta boxes, front-end forms, theme options) keeps the toolbar WordPress or its own plugin gives it. */ const EDITORS = array( 'content', 'classic-block' ); /** * Whether an editor gets our rows and buttons. * * @param mixed $editor_id Editor ID from the mce_buttons filters. */ public static function applies_to( $editor_id ): bool { $editor_id = is_string( $editor_id ) ? $editor_id : ''; /** * Filters whether an editor gets the toolbar put together under Settings > 3task Toolbar. * * @param bool $applies True for the post editor and the Classic block. * @param string $editor_id Editor ID, for example "content". */ return (bool) apply_filters( 'threece_toolbar_for_editor', in_array( $editor_id, self::EDITORS, true ), $editor_id ); } /** * Every button the toolbar builder knows, with its label. Others (from other plugins) are kept as they come. * * @return array */ public static function buttons() { return array( 'formatselect' => __( 'Paragraph format', '3task-toolbar-for-classic-editor' ), 'styleselect' => __( 'Formats', '3task-toolbar-for-classic-editor' ), 'fontselect' => __( 'Font family', '3task-toolbar-for-classic-editor' ), 'fontsizeselect' => __( 'Font size', '3task-toolbar-for-classic-editor' ), 'bold' => __( 'Bold', '3task-toolbar-for-classic-editor' ), 'italic' => __( 'Italic', '3task-toolbar-for-classic-editor' ), 'underline' => __( 'Underline', '3task-toolbar-for-classic-editor' ), 'strikethrough' => __( 'Strikethrough', '3task-toolbar-for-classic-editor' ), 'subscript' => __( 'Subscript', '3task-toolbar-for-classic-editor' ), 'superscript' => __( 'Superscript', '3task-toolbar-for-classic-editor' ), 'forecolor' => __( 'Text color', '3task-toolbar-for-classic-editor' ), 'backcolor' => __( 'Background color', '3task-toolbar-for-classic-editor' ), 'bullist' => __( 'Bulleted list', '3task-toolbar-for-classic-editor' ), 'numlist' => __( 'Numbered list', '3task-toolbar-for-classic-editor' ), 'blockquote' => __( 'Blockquote', '3task-toolbar-for-classic-editor' ), 'alignleft' => __( 'Align left', '3task-toolbar-for-classic-editor' ), 'aligncenter' => __( 'Align center', '3task-toolbar-for-classic-editor' ), 'alignright' => __( 'Align right', '3task-toolbar-for-classic-editor' ), 'alignjustify' => __( 'Justify', '3task-toolbar-for-classic-editor' ), 'outdent' => __( 'Decrease indent', '3task-toolbar-for-classic-editor' ), 'indent' => __( 'Increase indent', '3task-toolbar-for-classic-editor' ), 'link' => __( 'Insert/edit link', '3task-toolbar-for-classic-editor' ), 'unlink' => __( 'Remove link', '3task-toolbar-for-classic-editor' ), 'hr' => __( 'Horizontal line', '3task-toolbar-for-classic-editor' ), 'charmap' => __( 'Special character', '3task-toolbar-for-classic-editor' ), 'pastetext' => __( 'Paste as text', '3task-toolbar-for-classic-editor' ), 'removeformat' => __( 'Clear formatting', '3task-toolbar-for-classic-editor' ), 'undo' => __( 'Undo', '3task-toolbar-for-classic-editor' ), 'redo' => __( 'Redo', '3task-toolbar-for-classic-editor' ), 'ltr' => __( 'Left to right', '3task-toolbar-for-classic-editor' ), 'rtl' => __( 'Right to left', '3task-toolbar-for-classic-editor' ), 'wp_more' => __( 'Read more tag', '3task-toolbar-for-classic-editor' ), 'wp_page' => __( 'Page break', '3task-toolbar-for-classic-editor' ), 'wp_adv' => __( 'Toolbar toggle', '3task-toolbar-for-classic-editor' ), 'dfw' => __( 'Distraction-free writing', '3task-toolbar-for-classic-editor' ), 'fullscreen' => __( 'Fullscreen', '3task-toolbar-for-classic-editor' ), 'wp_help' => __( 'Keyboard shortcuts', '3task-toolbar-for-classic-editor' ), 'spellchecker' => __( 'Spell check (needs a spell check plugin)', '3task-toolbar-for-classic-editor' ), 'table' => __( 'Table', '3task-toolbar-for-classic-editor' ), 'threece_table_style' => __( 'Table style', '3task-toolbar-for-classic-editor' ), 'searchreplace' => __( 'Find and replace', '3task-toolbar-for-classic-editor' ), 'anchor' => __( 'Anchor', '3task-toolbar-for-classic-editor' ), 'code' => __( 'Source code', '3task-toolbar-for-classic-editor' ), 'visualblocks' => __( 'Show blocks', '3task-toolbar-for-classic-editor' ), 'visualchars' => __( 'Show invisible characters', '3task-toolbar-for-classic-editor' ), 'nonbreaking' => __( 'Non-breaking space', '3task-toolbar-for-classic-editor' ), 'insertdatetime' => __( 'Insert date/time', '3task-toolbar-for-classic-editor' ), 'threece_elements' => __( 'Insert element', '3task-toolbar-for-classic-editor' ), 'threece_emoji' => __( 'Insert emoji', '3task-toolbar-for-classic-editor' ), ); } /** * Icons that differ from the button name (TinyMCE takes the name otherwise); the four lists show text, no icon. * Measured in the post editor of WordPress 7.1.3 on 28.10.2026. */ const ICONS = array( 'formatselect' => '', 'styleselect' => '', 'fontselect' => '', 'fontsizeselect' => '', 'threece_elements' => 'dashicon dashicons-screenoptions', 'threece_table_style' => 'dashicon dashicons-art', 'threece_emoji' => 'dashicon dashicons-smiley', ); /** * Group of every button in the palette of the settings page: text, paragraph, insert or tools. */ const GROUPS = array( 'text' => array( 'styleselect', 'fontselect', 'fontsizeselect', 'bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript', 'forecolor', 'backcolor', 'removeformat', 'pastetext' ), 'paragraph' => array( 'formatselect', 'bullist', 'numlist', 'blockquote', 'alignleft', 'aligncenter', 'alignright', 'alignjustify', 'outdent', 'indent', 'hr', 'ltr', 'rtl' ), 'insert' => array( 'link', 'unlink', 'table', 'threece_table_style', 'threece_elements', 'threece_emoji', 'charmap', 'anchor', 'insertdatetime', 'nonbreaking', 'wp_more', 'wp_page' ), 'tools' => array( 'undo', 'redo', 'searchreplace', 'code', 'visualblocks', 'visualchars', 'wp_adv', 'dfw', 'fullscreen', 'wp_help', 'spellchecker' ), ); /** * Every known button for the toolbar preview: name, TinyMCE icon ('' = shown as text), palette group and whether it is * one of our own buttons. * * @return array */ public static function catalog() { $group = array(); foreach ( self::GROUPS as $name => $members ) { foreach ( $members as $button ) { $group[ $button ] = $name; } } $catalog = array(); foreach ( self::buttons() as $button => $label ) { $catalog[ $button ] = array( 'label' => $label, 'icon' => self::ICONS[ $button ] ?? $button, 'group' => $group[ $button ] ?? 'tools', 'ours' => 0 === strpos( $button, 'threece_' ), ); } return $catalog; } /** * Rows as WordPress builds them for the post editor, plus the table and our two buttons. * * @return array> */ public static function default_rows() { return array( array( 'formatselect', 'bold', 'italic', 'bullist', 'numlist', 'blockquote', 'alignleft', 'aligncenter', 'alignright', 'link', 'wp_more', 'wp_adv', 'dfw', 'threece_elements', 'threece_emoji' ), array( 'strikethrough', 'hr', 'forecolor', 'pastetext', 'removeformat', 'charmap', 'outdent', 'indent', 'undo', 'redo', 'wp_help', 'table', 'threece_table_style' ), array(), array(), ); } /** * Registers the setting so every save goes through sanitize(). */ public static function register_setting(): void { register_setting( self::OPTION, self::OPTION, array( 'type' => 'array', 'sanitize_callback' => array( __CLASS__, 'sanitize' ), 'show_in_rest' => false, ) ); } /** * Keeps known buttons and separators, each button once, at most four rows. Empty rows or "reset" mean * the WordPress default. * * @param mixed $input Raw value: rows as text ("bold italic | link") or as lists. * @return array{custom: bool, rows?: array>} */ public static function sanitize( $input ) { if ( ! is_array( $input ) || ! empty( $input['reset'] ) || ! isset( $input['rows'] ) || ! is_array( $input['rows'] ) ) { return array( 'custom' => false ); } $known = self::buttons(); $seen = array(); $rows = array(); foreach ( array_slice( array_values( $input['rows'] ), 0, self::ROWS ) as $raw ) { $tokens = is_array( $raw ) ? $raw : preg_split( '#[\s,]+#', is_string( $raw ) ? $raw : '' ); $row = array(); foreach ( (array) $tokens as $token ) { if ( ! is_string( $token ) ) { continue; } if ( '|' === $token ) { if ( $row && '|' !== end( $row ) ) { $row[] = '|'; } } elseif ( isset( $known[ $token ] ) && ! isset( $seen[ $token ] ) ) { $seen[ $token ] = true; $row[] = $token; } } while ( $row && '|' === end( $row ) ) { array_pop( $row ); } $rows[] = array_slice( $row, 0, self::ROW_SIZE ); } $rows = array_pad( $rows, self::ROWS, array() ); if ( ! array_filter( $rows ) ) { return array( 'custom' => false ); } return array( 'custom' => true, 'rows' => $rows, ); } /** * Stored toolbar, cleaned again. * * @return array{custom: bool, rows?: array>} */ public static function settings() { return self::sanitize( get_option( self::OPTION, array() ) ); } /** * Rows shown in the builder: the stored ones or the default. * * @return array> */ public static function current_rows() { $settings = self::settings(); return $settings['custom'] && isset( $settings['rows'] ) ? $settings['rows'] : self::default_rows(); } /** * First row. * * @param mixed $buttons Buttons from the filter. * @param mixed $editor_id Editor ID from the filter. * @return mixed */ public static function row_1( $buttons, $editor_id = '' ) { return self::row( $buttons, 1, $editor_id ); } /** * Second row. * * @param mixed $buttons Buttons from the filter. * @param mixed $editor_id Editor ID from the filter. * @return mixed */ public static function row_2( $buttons, $editor_id = '' ) { return self::row( $buttons, 2, $editor_id ); } /** * Third row. * * @param mixed $buttons Buttons from the filter. * @param mixed $editor_id Editor ID from the filter. * @return mixed */ public static function row_3( $buttons, $editor_id = '' ) { return self::row( $buttons, 3, $editor_id ); } /** * Fourth row. * * @param mixed $buttons Buttons from the filter. * @param mixed $editor_id Editor ID from the filter. * @return mixed */ public static function row_4( $buttons, $editor_id = '' ) { return self::row( $buttons, 4, $editor_id ); } /** * One toolbar row. Without an own toolbar WordPress decides, we only add the table and the table style to row 2. With one, the * row is ours; buttons of other plugins stay at the end. Advanced Editor Tools manages the toolbar itself. * * @param mixed $buttons Buttons from the filter. * @param int $number Row 1 to 4. * @param mixed $editor_id Editor ID from the filter. * @return mixed */ private static function row( $buttons, $number, $editor_id = '' ) { if ( ! is_array( $buttons ) || self::other_toolbar_plugin() || ! self::applies_to( $editor_id ) ) { return $buttons; } $writer = current_user_can( 'edit_posts' ); $settings = self::settings(); /** * Rows for the current user and editor. Null means the WordPress default toolbar; an add-on can hand in * four rows, for example per user role. Rows are lists of button names and '|'. * * @since 1.0.2 * @param array>|null $rows The stored rows, or null without an own toolbar. * @param string $editor_id Editor ID from the mce_buttons filter. */ $rows = apply_filters( 'threece_toolbar_rows', $settings['custom'] && isset( $settings['rows'] ) ? $settings['rows'] : null, (string) $editor_id ); if ( ! is_array( $rows ) ) { if ( 2 === $number && $writer ) { foreach ( array( 'table', 'threece_table_style' ) as $button ) { if ( ! in_array( $button, $buttons, true ) ) { $buttons[] = $button; } } } return $buttons; } $known = self::buttons(); $row = array(); foreach ( (array) ( array_values( $rows )[ $number - 1 ] ?? array() ) as $button ) { if ( is_string( $button ) && ( '|' === $button || isset( $known[ $button ] ) ) ) { $row[] = $button; } } $foreign = array(); foreach ( $buttons as $button ) { if ( is_string( $button ) && '|' !== $button && ! isset( $known[ $button ] ) ) { $foreign[] = $button; } } if ( ! $writer ) { $row = array_values( array_diff( $row, self::OWN_BUTTONS ) ); } return array_merge( $row, $foreign ); } /** * Advanced Editor Tools (formerly TinyMCE Advanced) brings the same extensions. */ public static function other_toolbar_plugin(): bool { if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } return is_plugin_active( 'tinymce-advanced/tinymce-advanced.php' ); } /** * Registers the extensions for users who write posts, without replacing one another plugin registered. * * @param mixed $plugins External TinyMCE plugins from the filter. * @return mixed */ public static function extensions( $plugins ) { if ( ! is_array( $plugins ) || ! current_user_can( 'edit_posts' ) || self::other_toolbar_plugin() ) { return $plugins; } foreach ( self::EXTENSIONS as $name ) { if ( ! isset( $plugins[ $name ] ) ) { $plugins[ $name ] = THREECE_URL . 'assets/tinymce/plugins/' . $name . '/plugin.min.js?ver=4.9.11'; } } return $plugins; } }