GM Crop 300 — плагин обрезки миниатюры

GM Crop 300 — плагин обрезки миниатюры под квадрат 300×300
Что это: плагин для WordPress, который добавляет в редактор записи кнопку «✂️ Обрезать 300×300». Открывает окно с фиксированной квадратной рамкой, позволяет подвигать картинку, выбрать нужный фрагмент — и сохраняет результат как WebP 300×300, автоматически заменяя миниатюру записи.

Кому нужно: блогам и сайтам, где миниатюры записей должны быть строго квадратными — единый дизайн сетки, ровные карточки, никаких «растянутых» или обрезанных по-разному превью.

Куда класть: wp-content/plugins/gm-crop-300.php — один файл, без папок.

Установка: Плагины → активировать. Кнопка появится в редакторе записи справа, под блоком «Миниатюра записи».

Что делает плагин
В редакторе записи (справа, под миниатюрой) появляется кнопка ✂️ Обрезать 300×300

По клику открывается модальное окно с фиксированной рамкой 300×300 по центру

Картинку можно двигать мышкой и зумить колёсиком — рамка остаётся неподвижной

По кнопке «Сохранить» создаётся WebP-файл 300×300 и добавляется в медиабиблиотеку

Новая картинка автоматически становится миниатюрой записи

Никаких ручных обрезок в Photoshop, никаких плагинов «thumbnails regenerator». Всё в один клик.

Почему WebP
Вес на 30–50% меньше, чем JPEG при том же качестве

Поддерживается всеми современными браузерами (Chrome, Firefox, Safari 14+, Edge)

Меньше вес — быстрее сайт — лучше SEO и UX

Плагин сам конвертирует картинку в WebP. Отдельно ничего ставить не надо.

Поддерживаемые форматы исходников
Формат Обработка
JPEG ✅
PNG ✅ (с сохранением прозрачности)
WebP ✅
GIF ❌ (не поддерживается)
Для работы нужна библиотека Imagick или GD (одна из двух). На большинстве хостингов есть хотя бы GD.

Как пользоваться
Откройте запись на редактирование

Установите миниатюру записи обычным способом (если ещё не установлена)

Справа появится кнопка ✂️ Обрезать 300×300

Нажмите — откроется окно

В окне:

Двигайте картинку мышкой — рамка остаётся на месте

Колёсико мыши — зум

💾 Сохранить — создать WebP и заменить миниатюру

Отмена или Esc — закрыть без изменений

После сохранения — страница перезагрузится, миниатюра обновится.

Технические детали
Плагин построен на Cropper.js — популярной JS-библиотеке для кропа (загружается с CDN, ~50 КБ).

Настройки кропа:

aspectRatio: 1 — квадрат

viewMode: 1 — картинка не выходит за рамку

cropBoxMovable: false, cropBoxResizable: false — рамка неподвижна

dragMode: ‘move’ — двигается именно картинка

Координаты кропа передаются на сервер в процентах от оригинала — это надёжнее, чем в пикселях (не зависит от масштаба, в котором отображалась картинка).

Серверная часть:

Принимает координаты

Пересчитывает в пиксели от оригинала

Вырезает область, ресайзит до 300×300

Сохраняет как WebP с качеством 85

Загружает в медиабиблиотеку

Заменяет _thumbnail_id у записи

Код плагина:


<?php
/**
 * Plugin Name: GM Crop 300
 * Description: Кнопка под миниатюрой записи. Открывает окно с рамкой 300×300 (картинка двигается). Сохраняет обрезанную WebP как миниатюру записи.
 * Version:     1.0.0
 * Author:      Сергей Ведерников
 * License:     GPL-2.0+
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

define( 'GMC300_W', 300 );
define( 'GMC300_H', 300 );
define( 'GMC300_Q', 85 );

/**
 * ============================================================
 * 1. МЕТАБОКС С КНОПКОЙ (справа под миниатюрой)
 * ============================================================
 */
add_action( 'add_meta_boxes', 'gmc300_metabox' );
function gmc300_metabox() {
    add_meta_box(
        'gmc300_box',
        '✂️ Обрезка миниатюры 300×300',
        'gmc300_render_metabox',
        array( 'post', 'page' ),
        'side',
        'high'
    );
}

function gmc300_render_metabox( $post ) {

    $thumb_id = get_post_thumbnail_id( $post->ID );

    if ( ! $thumb_id ) {
        echo '<p style="color:#dc2626;font-size:13px;">Сначала установите миниатюру записи.</p>';
        return;
    }

    $full_url = wp_get_attachment_image_url( $thumb_id, 'full' );

    ?>
    <p style="font-size:13px;color:#64748b;margin:0 0 10px 0;">
        Откроется окно с рамкой 300×300. <strong>Двигайте картинку</strong> — выберите, что останется.
    </p>

    <button type="button"
            class="button button-primary"
            style="width:100%;"
            id="gmc300-open"
            data-img="<?php echo esc_url( $full_url ); ?>"
            data-post="<?php echo (int) $post->ID; ?>">
        ✂️ Обрезать 300×300
    </button>
    <?php
}

/**
 * ============================================================
 * 2. CSS + HTML + JS — В ФУТЕРЕ РЕДАКТОРА
 * ============================================================
 */
add_action( 'admin_footer', 'gmc300_admin_footer' );
function gmc300_admin_footer() {
    global $pagenow;

    if ( ! in_array( $pagenow, array( 'post.php', 'post-new.php' ), true ) ) {
        return;
    }

    $screen = get_current_screen();
    if ( ! $screen || ! in_array( $screen->post_type, array( 'post', 'page' ), true ) ) {
        return;
    }
    ?>
    <!-- Cropper.js -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js"></script>

    <style>
    #gmc300-modal {
        display: none;
        position: fixed;
        inset: 0;
        background: rgba(0,0,0,0.92);
        z-index: 999999;
        align-items: center;
        justify-content: center;
    }
    #gmc300-modal.open { display: flex; }

    .gmc300-box {
        background: #fff;
        border-radius: 12px;
        width: 90vw;
        max-width: 700px;
        max-height: 90vh;
        display: flex;
        flex-direction: column;
        overflow: hidden;
        box-shadow: 0 20px 60px rgba(0,0,0,0.6);
    }

    .gmc300-header {
        padding: 14px 22px;
        background: #1e293b;
        color: #fff;
        display: flex;
        justify-content: space-between;
        align-items: center;
    }
    .gmc300-header h2 { margin: 0; font-size: 16px; color: #fff; }
    .gmc300-close {
        background: transparent; border: none; color: #fff;
        font-size: 22px; cursor: pointer; line-height: 1; padding: 0 6px;
    }
    .gmc300-close:hover { color: #f87171; }

    /* Тело окна — 400px высотой, обрезка 300×300 в центре */
    .gmc300-body {
        padding: 0;
        background: #0f172a;
        height: 400px;
        display: flex;
        justify-content: center;
        align-items: center;
        overflow: hidden;
        position: relative;
    }

    /* Контейнер Cropper — 300×300, по центру */
    .gmc300-cropper-wrap {
        width: 300px;
        height: 300px;
        position: relative;
        overflow: hidden;
        border-radius: 8px;
        box-shadow: 0 0 0 2px #2563eb, 0 0 0 9999px rgba(0,0,0,0.5);
    }

    .gmc300-cropper-wrap img {
        display: block;
        max-width: 100%;
    }

    .gmc300-footer {
        padding: 14px 22px;
        background: #f8fafc;
        border-top: 1px solid #e2e8f0;
        display: flex;
        justify-content: space-between;
        align-items: center;
        gap: 15px;
    }
    .gmc300-info { font-size: 13px; color: #64748b; }

    .gmc300-btn {
        padding: 10px 22px;
        border-radius: 8px;
        border: none;
        font-weight: 600;
        font-size: 14px;
        cursor: pointer;
        transition: all 0.2s;
    }
    .gmc300-btn.primary {
        background: #16a34a; color: #fff;
    }
    .gmc300-btn.primary:hover { background: #15803d; }
    .gmc300-btn.primary:disabled { background: #cbd5e1; cursor: not-allowed; }
    .gmc300-btn.secondary {
        background: #e2e8f0; color: #1e293b;
    }
    .gmc300-btn.secondary:hover { background: #cbd5e1; }

    .gmc300-spinner {
        display: none;
        border: 3px solid #e2e8f0;
        border-top: 3px solid #16a34a;
        border-radius: 50%;
        width: 18px; height: 18px;
        animation: gmc300spin 0.8s linear infinite;
    }
    .gmc300-spinner.on { display: inline-block; }
    @keyframes gmc300spin { to { transform: rotate(360deg); } }
    </style>

    <div id="gmc300-modal">
        <div class="gmc300-box">
            <div class="gmc300-header">
                <h2>✂️ Обрезка миниатюры 300×300</h2>
                <button type="button" class="gmc300-close" onclick="gmc300Close()">✕</button>
            </div>

            <div class="gmc300-body">
                <div class="gmc300-cropper-wrap">
                    <img id="gmc300-img" src="" alt="">
                </div>
            </div>

            <div class="gmc300-footer">
                <div class="gmc300-info">
                    🖱 <strong>Двигайте картинку</strong> мышкой. Колёсико — зум.
                </div>
                <div style="display:flex;gap:10px;align-items:center;">
                    <div class="gmc300-spinner" id="gmc300-spinner"></div>
                    <button type="button" class="gmc300-btn secondary" onclick="gmc300Close()">Отмена</button>
                    <button type="button" class="gmc300-btn primary" id="gmc300-save" onclick="gmc300Save()">
                        💾 Сохранить
                    </button>
                </div>
            </div>
        </div>
    </div>

    <script>
    (function() {
        'use strict';

        var cropper   = null;
        var ajaxUrl   = '<?php echo esc_js( admin_url( "admin-ajax.php" ) ); ?>';
        var nonce     = '<?php echo esc_js( wp_create_nonce( "gmc300_save" ) ); ?>';

        window.gmc300Open = function(imageUrl, postId) {
            var modal = document.getElementById('gmc300-modal');
            var img   = document.getElementById('gmc300-img');

            if (!modal || !img) return;

            img.src = imageUrl;
            modal.classList.add('open');
            modal.setAttribute('data-post-id', postId);

            img.onload = function() {
                if (cropper) cropper.destroy();

                cropper = new Cropper(img, {
                    aspectRatio: 1,
                    viewMode: 1,
                    autoCropArea: 1,
                    movable: true,
                    zoomable: true,
                    rotatable: false,
                    scalable: false,
                    guides: false,
                    center: false,
                    highlight: false,
                    cropBoxMovable: false,
                    cropBoxResizable: false,
                    dragMode: 'move',
                    background: true,
                    modal: false,
                    ready: function() {
                        this.cropper.setCropBoxData({
                            left: 0,
                            top: 0,
                            width: 300,
                            height: 300
                        });
                    }
                });
            };

            if (img.complete) img.onload();
        };

        window.gmc300Close = function() {
            var modal = document.getElementById('gmc300-modal');
            if (modal) modal.classList.remove('open');
            if (cropper) { cropper.destroy(); cropper = null; }
        };

        window.gmc300Save = function() {
            if (!cropper) return;

            var btn     = document.getElementById('gmc300-save');
            var spinner = document.getElementById('gmc300-spinner');
            var modal   = document.getElementById('gmc300-modal');
            var postId  = modal.getAttribute('data-post-id');

            var data = cropper.getData(true);
            var imageData = cropper.getImageData();

            var x = data.x / imageData.naturalWidth;
            var y = data.y / imageData.naturalHeight;
            var w = data.width / imageData.naturalWidth;
            var h = data.height / imageData.naturalHeight;

            btn.disabled = true;
            btn.textContent = '⏳ Сохраняю...';
            spinner.classList.add('on');

            var formData = new FormData();
            formData.append('action', 'gmc300_save');
            formData.append('nonce', nonce);
            formData.append('post_id', postId);
            formData.append('x', x);
            formData.append('y', y);
            formData.append('w', w);
            formData.append('h', h);

            fetch(ajaxUrl, {
                method: 'POST',
                body: formData,
                credentials: 'same-origin'
            })
            .then(function(r) { return r.json(); })
            .then(function(response) {
                btn.disabled = false;
                btn.textContent = '💾 Сохранить';
                spinner.classList.remove('on');

                if (response.success) {
                    alert('✅ Миниатюра обновлена!');
                    gmc300Close();
                    location.reload();
                } else {
                    alert('❌ ' + (response.data && response.data.message ? response.data.message : 'Ошибка'));
                }
            })
            .catch(function() {
                btn.disabled = false;
                btn.textContent = '💾 Сохранить';
                spinner.classList.remove('on');
                alert('❌ Ошибка AJAX');
            });
        };

        document.addEventListener('DOMContentLoaded', function() {

            document.addEventListener('click', function(e) {
                var btn = e.target.closest('#gmc300-open');
                if (!btn) return;
                e.preventDefault();

                var img  = btn.getAttribute('data-img');
                var post = btn.getAttribute('data-post');

                if (!img) { alert('Миниатюра не найдена'); return; }
                gmc300Open(img, post);
            });

            document.addEventListener('keydown', function(e) {
                if (e.key === 'Escape') {
                    var modal = document.getElementById('gmc300-modal');
                    if (modal && modal.classList.contains('open')) gmc300Close();
                }
            });

            var modal = document.getElementById('gmc300-modal');
            if (modal) {
                modal.addEventListener('click', function(e) {
                    if (e.target === this) gmc300Close();
                });
            }
        });

    })();
    </script>
    <?php
}

/**
 * ============================================================
 * 3. AJAX — СОХРАНЕНИЕ И ЗАМЕНА МИНИАТЮРЫ
 * ============================================================
 */
add_action( 'wp_ajax_gmc300_save', 'gmc300_ajax_save' );
function gmc300_ajax_save() {

    check_ajax_referer( 'gmc300_save', 'nonce' );

    if ( ! current_user_can( 'upload_files' ) ) {
        wp_send_json_error( array( 'message' => 'Нет доступа' ) );
    }

    $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
    $x       = isset( $_POST['x'] ) ? floatval( $_POST['x'] ) : 0;
    $y       = isset( $_POST['y'] ) ? floatval( $_POST['y'] ) : 0;
    $w       = isset( $_POST['w'] ) ? floatval( $_POST['w'] ) : 0;
    $h       = isset( $_POST['h'] ) ? floatval( $_POST['h'] ) : 0;

    if ( ! $post_id ) {
        wp_send_json_error( array( 'message' => 'Нет ID записи' ) );
    }

    $thumb_id = get_post_thumbnail_id( $post_id );
    if ( ! $thumb_id ) {
        wp_send_json_error( array( 'message' => 'Нет миниатюры' ) );
    }

    $file = get_attached_file( $thumb_id );
    if ( ! $file || ! file_exists( $file ) ) {
        wp_send_json_error( array( 'message' => 'Файл не найден' ) );
    }

    $size = @getimagesize( $file );
    if ( ! $size ) {
        wp_send_json_error( array( 'message' => 'Не удалось прочитать' ) );
    }

    $orig_w = $size[0];
    $orig_h = $size[1];

    $crop_x = (int) round( $x * $orig_w );
    $crop_y = (int) round( $y * $orig_h );
    $crop_w = (int) round( $w * $orig_w );
    $crop_h = (int) round( $h * $orig_h );

    if ( $crop_w < 20 || $crop_h < 20 ) {
        wp_send_json_error( array( 'message' => 'Область слишком маленькая' ) );
    }

    $result = gmc300_make_webp( $file, $crop_x, $crop_y, $crop_w, $crop_h );

    if ( ! $result ) {
        wp_send_json_error( array( 'message' => 'Не удалось создать WebP' ) );
    }

    update_post_meta( $post_id, '_thumbnail_id', $result['attachment_id'] );

    wp_send_json_success( array(
        'url' => $result['url'],
        'id'  => $result['attachment_id'],
    ) );
}

/**
 * Создать WebP 300×300 и добавить в медиабиблиотеку.
 */
function gmc300_make_webp( $src, $x, $y, $w, $h ) {

    $tmp = wp_tempnam( 'gmc300' ) . '.webp';

    if ( extension_loaded( 'imagick' ) ) {
        try {
            $img = new Imagick( $src );
            $img->cropImage( $w, $h, $x, $y );
            $img->resizeImage( GMC300_W, GMC300_H, Imagick::FILTER_LANCZOS, 1, true );
            $img->setImageFormat( 'webp' );
            $img->setImageCompressionQuality( GMC300_Q );
            $img->stripImage();
            $ok = $img->writeImage( $tmp );
            $img->clear();
            $img->destroy();
        } catch ( Exception $e ) {
            return false;
        }
    } elseif ( extension_loaded( 'gd' ) && function_exists( 'imagewebp' ) ) {
        $info = @getimagesize( $src );
        if ( ! $info ) return false;

        switch ( $info['mime'] ) {
            case 'image/jpeg': $im = @imagecreatefromjpeg( $src ); break;
            case 'image/png':  $im = @imagecreatefrompng( $src );  break;
            case 'image/webp': $im = @imagecreatefromwebp( $src ); break;
            default: return false;
        }

        if ( ! $im ) return false;

        $dst = imagecreatetruecolor( GMC300_W, GMC300_H );
        imagealphablending( $dst, false );
        imagesavealpha( $dst, true );
        imagecopyresampled( $dst, $im, 0, 0, $x, $y, GMC300_W, GMC300_H, $w, $h );

        $ok = imagewebp( $dst, $tmp, GMC300_Q );
        imagedestroy( $im );
        imagedestroy( $dst );

        if ( ! $ok ) return false;
    } else {
        return false;
    }

    if ( ! file_exists( $tmp ) ) return false;

    $upload = wp_upload_bits(
        'thumbnail-crop-' . time() . '.webp',
        null,
        file_get_contents( $tmp )
    );

    @unlink( $tmp );

    if ( ! empty( $upload['error'] ) ) {
        return false;
    }

    $attachment = array(
        'post_mime_type' => 'image/webp',
        'post_title'     => 'Обрезанная миниатюра 300×300',
        'post_content'   => '',
        'post_status'    => 'inherit',
    );

    $attach_id = wp_insert_attachment( $attachment, $upload['file'] );

    if ( ! $attach_id ) return false;

    require_once ABSPATH . 'wp-admin/includes/image.php';
    $meta = wp_generate_attachment_metadata( $attach_id, $upload['file'] );
    wp_update_attachment_metadata( $attach_id, $meta );

    return array(
        'attachment_id' => $attach_id,
        'url'           => $upload['url'],
        'file'          => $upload['file'],
    );
}


Что важно помнить
Миниатюра должна быть установлена до нажатия кнопки. Если её нет — плагин покажет подсказку.

Исходник не удаляется — создаётся новый файл, старый остаётся в медиабиблиотеке. Если передумали — можно вернуть прежнюю миниатюру вручную.

Работает для записей и страниц (post, page). Если нужно для других типов — добавьте их в add_meta_box.

Требуется HTTPS — для AJAX-запросов и работы медиабиблиотеки.

Где что лежит
Файл: wp-content/plugins/gm-crop-300.php

Хук AJAX: wp_ajax_gmc300_save

Мета-ключ миниатюры: стандартный _thumbnail_id

Оставить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

🧮 Проверка безопасности
10 - 1 = ?