Vanhiep.NET - Chuyên gia Thiết kế Website & Ứng dụng

Tối ưu hóa quản lý ảnh: Xây dựng Modal chọn ảnh phân trang cho CodeIgniter & Tích hợp CKEditor

"Khám phá hướng dẫn chi tiết cách tích hợp modal chọn ảnh từ thư viện vào ứng dụng CodeIgniter của bạn, hoàn chỉnh với tính năng phân trang tiện lợi. Bài viết này sẽ hướng dẫn bạn từng bước từ cấu hình controller PHP đến xây dựng giao diện HTML, CSS và logic JavaScript (hướng đối tượng) để quản lý ảnh một cách hiệu quả. Đặc biệt, bạn sẽ học cách đồng bộ hóa trạng thái giữa việc tải lên ảnh mới và chọn ảnh có sẵn, đảm bảo trải nghiệm người dùng liền mạch và dữ liệu nhất quán. Đây là giải pháp lý tưởng cho việc nâng cao chức năng quản lý hình ảnh trong các biểu mẫu hoặc trình soạn thảo như CKEditor."

Hướng dẫn Tích hợp Modal Chọn Ảnh với Phân trang cho CKEditor

Hướng dẫn này sẽ giúp bạn triển khai một modal tùy chỉnh để chọn ảnh từ thư viện với chức năng phân trang, tích hợp tốt với các trường hợp sử dụng trong CKEditor hoặc các biểu mẫu khác, đồng thời quản lý nhất quán giữa việc tải lên file mới và chọn ảnh có sẵn.

---

Các Bước Triển Khai

1. Cập nhật PHP Controller (application/controllers/Upload.php)

Đây là nơi xử lý logic lấy danh sách ảnh từ máy chủ với phân trang.

<?php
defined('BASEPATH') OR exit('No direct script access allowed');

class Upload extends CI_Controller {

    public function __construct() {
        parent::__construct();
        $this->load->helper('url');
        $this->load->helper('file');
    }

    public function index() {
        // Đổi tên view để phân biệt
        $this->load->view('upload_view_custom_modal_paginated');
    }

    // Phương thức để lấy ảnh với phân trang
    public function get_images_paginated() {
        $base_upload_dir = 'upload/'; // Đường dẫn gốc của thư mục upload

        // Lấy tham số trang hiện tại và giới hạn số ảnh từ AJAX request
        $page = $this->input->get('page') ? (int)$this->input->get('page') : 1;
        $limit = $this->input->get('limit') ? (int)$this->input->get('limit') : 60; // Mặc định 60 ảnh/trang

        $all_images = [];
        $this->_scan_dir_for_images($base_upload_dir, $all_images);

        // Sắp xếp ảnh theo tên để đảm bảo thứ tự nhất quán khi phân trang
        sort($all_images);

        $total_images = count($all_images);
        $total_pages = ceil($total_images / $limit);

        // Tính toán offset (vị trí bắt đầu của các ảnh cho trang hiện tại)
        $offset = ($page - 1) * $limit;

        // Lấy các ảnh cho trang hiện tại
        $paged_images = array_slice($all_images, $offset, $limit);

        // Trả về dữ liệu phân trang
        echo json_encode([
            'images' => $paged_images,
            'total_images' => $total_images,
            'total_pages' => $total_pages,
            'current_page' => $page,
            'limit' => $limit
        ]);
    }

    /**
     * Hàm đệ quy để quét các thư mục và lấy tất cả ảnh.
     * @param string $dir_path Đường dẫn thư mục hiện tại để quét.
     * @param array $images Mảng tham chiếu để lưu trữ các URL ảnh tìm được.
     */
    private function _scan_dir_for_images($dir_path, &$images) {
        if (substr($dir_path, -1) !== '/') {
            $dir_path .= '/';
        }

        $scan = @scandir($dir_path);

        if ($scan === false) {
            log_message('error', 'Không thể quét thư mục: ' . $dir_path);
            return;
        }

        foreach ($scan as $item) {
            if ($item == '.' || $item == '..') {
                continue;
            }

            $item_path = $dir_path . $item;

            if (is_dir($item_path)) {
                $this->_scan_dir_for_images($item_path, $images);
            } else if (is_file($item_path)) {
                $file_extension = pathinfo($item_path, PATHINFO_EXTENSION);
                $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif'];

                if (in_array(strtolower($file_extension), $allowed_extensions)) {
                    $images[] = base_url($item_path);
                }
            }
        }
    }

    // Phương thức xử lý tải ảnh lên (giữ nguyên)
    public function do_upload() {
        $config['upload_path']   = './upload/';
        $config['allowed_types'] = 'gif|jpg|png';
        $config['max_size']      = 1024; // 1MB
        $config['max_width']     = 1024;
        $config['max_height']    = 768;

        $this->load->library('upload', $config);

        if ( ! $this->upload->do_upload('userfile')) {
            $error = array('error' => $this->upload->display_errors());
            echo json_encode($error);
        } else {
            $data = array('upload_data' => $this->upload->data());
            echo json_encode(['success' => true, 'file_name' => $data['upload_data']['file_name']]);
        }
    }
}
---

2. Cập nhật HTML, CSS & JavaScript (View)

Tạo file application/views/upload_view_custom_modal_paginated.php và dán toàn bộ mã dưới đây vào. Mã JavaScript đã được tổ chức theo hướng đối tượng và xử lý logic đồng bộ giữa input file và input ẩn.

<!DOCTYPE html>
<html>
<head>
    <title>Upload Hình ảnh (Custom Modal - Phân trang)</title>
    <style>
        /* CSS cho modal tùy chỉnh */
        .custom-modal-overlay {
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0, 0, 0, 0.7);
            z-index: 1000;
            justify-content: center;
            align-items: center;
        }

        .custom-modal-content {
            background-color: #fff;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
            max-width: 900px; /* Điều chỉnh giá trị này để có chiều rộng mong muốn */
            width: 90%; /* Hoặc dùng width: 90% */
            min-width: 500px; /* Thêm min-width để tránh bị quá hẹp */

            max-height: 90vh;
            position: relative;
            display: flex;
            flex-direction: column;
        }

        .custom-modal-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            border-bottom: 1px solid #eee;
            padding-bottom: 10px;
            margin-bottom: 15px;
            flex-shrink: 0;
        }

        .custom-modal-title {
            margin: 0;
            font-size: 1.5em;
        }

        .custom-modal-close-btn {
            background: none;
            border: none;
            font-size: 2em;
            cursor: pointer;
            color: #aaa;
            line-height: 1;
            position: absolute;
            top: 10px;
            right: 15px;
        }

        .custom-modal-close-btn:hover {
            color: #666;
        }

        .custom-modal-body {
            flex-grow: 1;
            overflow-y: auto; /* Giữ overflow-y ở đây để cuộn nội dung nếu cần */
            padding-right: 10px;
            padding-bottom: 10px;
        }

        /* CSS cho lưới ảnh */
        .image-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); /* Điều chỉnh giá trị 120px này */
            gap: 10px;
            width: 100%;
        }

        .image-grid img {
            width: 100%;
            height: 100px; /* Chiều cao cố định cho tất cả ảnh */
            object-fit: cover; /* Cắt ảnh để vừa với kích thước mà không làm biến dạng */
            cursor: pointer;
            border: 2px solid transparent;
            display: block;
        }
        .image-grid img:hover {
            border: 2px solid blue;
        }

        /* CSS cho phân trang */
        .pagination-controls {
            display: flex;
            justify-content: center;
            align-items: center;
            margin-top: 15px;
            flex-shrink: 0;
        }

        .pagination-controls button {
            background-color: #f0f0f0;
            border: 1px solid #ddd;
            padding: 8px 12px;
            margin: 0 5px;
            cursor: pointer;
            border-radius: 4px;
        }

        .pagination-controls button:hover:not(:disabled) {
            background-color: #e0e0e0;
        }

        .pagination-controls button:disabled {
            cursor: not-allowed;
            opacity: 0.5;
        }

        .pagination-controls span {
            font-weight: bold;
            margin: 0 10px;
        }

        /* Các kiểu cơ bản cho form-group để dễ nhìn */
        .form-group {
            margin-bottom: 15px;
        }
        .col-sm-12 {
            margin-bottom: 10px;
        }
        .btn {
            padding: 8px 15px;
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        .btn:hover {
            background-color: #0056b3;
        }
    </style>
</head>
<body>

<div class="container">
    <div class="form-group">
        <label class="col-sm-12">Hình ảnh</label>
        <div class="col-sm-12">
            <input type="file" name="userfile" id="input_img" onchange="handleFiles()">
            <button type="button" class="btn" id="openImageModalBtn">Chọn ảnh từ thư viện</button>
        </div>
        <div class="clearfix"></div>
        <br>
        <div class="col-sm-12" id="view_img">
            <img src="https://vanhiep.net/img/noimage.png" id="noimage_review" style="max-width: 200px; height: auto; display: block;">
            <!-- Input ẩn để lưu đường dẫn ảnh từ thư viện -->
            <input type="hidden" name="link_image_thump" id="link_image_thump" value="">
        </div>
    </div>
</div>

<!-- Custom Modal -->
<div class="custom-modal-overlay" id="imageModal">
    <div class="custom-modal-content">
        <div class="custom-modal-header">
            <h4 class="custom-modal-title">Chọn hình ảnh</h4>
            <button type="button" class="custom-modal-close-btn" id="closeImageModalBtn">&times;</button>
        </div>
        <div class="custom-modal-body">
            <div class="image-grid" id="imageGridContent">
                <!-- Images will be loaded here via AJAX -->
            </div>
        </div>
        <!-- Vùng điều khiển phân trang -->
        <div class="pagination-controls" id="paginationControls">
            <button id="prevPageBtn" disabled>&larr; Trang trước</button>
            <span id="currentPageInfo">Trang 1 / 1</span>
            <button id="nextPageBtn" disabled>Trang sau &rarr;</button>
        </div>
    </div>
</div>

<!-- Sử dụng jQuery để đơn giản hóa AJAX và thao tác DOM -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script>
    // Hàm xử lý file trực tiếp (khi người dùng chọn file để tải lên)
    function handleFiles() {
        const fileInput = document.getElementById('input_img');
        const imgPreview = document.getElementById('noimage_review'); // Hoặc id bạn muốn dùng

        if (fileInput.files && fileInput.files[0]) {
            const reader = new FileReader();
            reader.onload = function(e) {
                imgPreview.src = e.target.result;
            };
            reader.readAsDataURL(fileInput.files[0]);

            // Khi người dùng chọn file mới, xóa giá trị của input link_image_thump
            $('#link_image_thump').val('');
        }
    }

    // Định nghĩa lớp ImageSelector để quản lý modal chọn ảnh và phân trang
    class ImageSelector {
        constructor(options) {
            // Khai báo các thuộc tính (elements và trạng thái)
            this.modal = $(options.modalId);
            this.imageGridContent = $(options.imageGridContentId);
            this.prevPageBtn = $(options.prevPageBtnId);
            this.nextPageBtn = $(options.nextPageBtnId);
            this.currentPageInfo = $(options.currentPageInfoId);
            this.openModalBtn = $(options.openModalBtnId);
            this.closeModalBtn = $(options.closeModalBtnId);
            this.targetImageReviewIds = options.targetImageReviewIds || []; // Mảng các ID để cập nhật ảnh xem trước
            this.linkImageThumpInput = $(options.linkImageThumpInputId); // Input ẩn lưu URL ảnh từ thư viện
            this.fileInput = $(options.fileInputId); // Input type="file"

            this.currentPage = 1;
            this.imagesPerPage = options.imagesPerPage || 60;
            this.ajaxUrl = options.ajaxUrl;

            // Gọi phương thức khởi tạo sự kiện
            this.initEvents();
        }

        initEvents() {
            // Sự kiện mở modal
            this.openModalBtn.on('click', () => {
                this.currentPage = 1; // Reset về trang đầu tiên mỗi khi mở modal
                this.loadImagesIntoModal(this.currentPage);
                this.modal.css('display', 'flex'); // Hiển thị modal
            });

            // Sự kiện đóng modal
            this.closeModalBtn.on('click', () => {
                this.modal.css('display', 'none'); // Ẩn modal
            });

            // Sự kiện click ra ngoài modal để đóng
            this.modal.on('click', (event) => {
                if ($(event.target).is(this.modal)) {
                    this.modal.css('display', 'none');
                }
            });

            // Sự kiện click nút "Trang trước"
            this.prevPageBtn.on('click', () => {
                if (this.currentPage > 1) {
                    this.currentPage--;
                    this.loadImagesIntoModal(this.currentPage);
                }
            });

            // Sự kiện click nút "Trang sau"
            this.nextPageBtn.on('click', () => {
                // Lấy tổng số trang từ thông tin hiện tại trên UI (đã được cập nhật từ AJAX)
                let totalPages = parseInt(this.currentPageInfo.text().split('/')[1].trim());
                if (this.currentPage < totalPages) {
                    this.currentPage++;
                    this.loadImagesIntoModal(this.currentPage);
                }
            });

            // Sự kiện click vào ảnh trong lưới (sử dụng event delegation)
            this.imageGridContent.on('click', 'img', (event) => {
                const selectedImageUrl = $(event.currentTarget).data('image-url');
                this.updateTargetImage(selectedImageUrl); // Cập nhật ảnh xem trước

                // Gán URL ảnh đã chọn vào input link_image_thump
                this.linkImageThumpInput.val(selectedImageUrl);

                // Xóa giá trị của input type="file" nếu người dùng chuyển từ tải file sang chọn thư viện
                this.fileInput.val('');

                this.modal.css('display', 'none'); // Đóng modal
            });
        }

        // Tải ảnh từ server vào modal với phân trang
        loadImagesIntoModal(page) {
            this.imageGridContent.html('<p>Đang tải ảnh...</p>'); // Hiển thị thông báo đang tải
            $.ajax({
                url: this.ajaxUrl,
                type: 'GET',
                dataType: 'json',
                data: {
                    page: page,
                    limit: this.imagesPerPage
                },
                success: (response) => {
                    let imageGridHtml = '';
                    if (response.images && response.images.length > 0) {
                        response.images.forEach(function(imageUrl) {
                            imageGridHtml += `<img src="${imageUrl}" class="img-thumbnail" data-image-url="${imageUrl}">`;
                        });
                    } else {
                        imageGridHtml = '<p>Không có hình ảnh nào trên trang này.</p>';
                    }
                    this.imageGridContent.html(imageGridHtml); // Đổ HTML vào lưới ảnh

                    // Cập nhật thông tin phân trang
                    this.currentPageInfo.text(`Trang ${response.current_page} / ${response.total_pages}`);

                    // Cập nhật trạng thái (disabled) của các nút phân trang
                    this.prevPageBtn.prop('disabled', response.current_page <= 1);
                    this.nextPageBtn.prop('disabled', response.current_page >= response.total_pages);

                    // Nếu chỉ có 1 trang hoặc không có ảnh, vô hiệu hóa cả 2 nút
                    if (response.total_pages <= 1) {
                        this.prevPageBtn.prop('disabled', true);
                        this.nextPageBtn.prop('disabled', true);
                    }
                },
                error: (xhr, status, error) => {
                    console.error("Lỗi khi tải ảnh:", status, error);
                    this.imageGridContent.html('<p>Có lỗi khi tải hình ảnh.</p>');
                    // Vô hiệu hóa nút khi lỗi
                    this.prevPageBtn.prop('disabled', true);
                    this.nextPageBtn.prop('disabled', true);
                    this.currentPageInfo.text('Lỗi tải trang');
                }
            });
        }

        // Cập nhật thẻ img xem trước dựa trên mảng các ID ưu tiên
        updateTargetImage(imageUrl) {
            let found = false;
            for (const id of this.targetImageReviewIds) {
                const targetElement = $(`#${id}`);
                if (targetElement.length > 0) { // Kiểm tra xem phần tử có tồn tại không
                    targetElement.attr('src', imageUrl);
                    found = true;
                    break; // Dừng lại ngay khi tìm thấy và cập nhật thẻ đầu tiên
                }
            }
            if (!found) {
                console.warn(`Không tìm thấy thẻ img với bất kỳ id nào trong [${this.targetImageReviewIds.join(', ')}] để cập nhật.`);
            }
        }
    }

    // Khởi tạo đối tượng ImageSelector khi DOM đã sẵn sàng
    $(document).ready(function() {
        const imageSelectorOptions = {
            modalId: '#imageModal',
            imageGridContentId: '#imageGridContent',
            prevPageBtnId: '#prevPageBtn',
            nextPageBtnId: '#nextPageBtn',
            currentPageInfoId: '#currentPageInfo',
            openModalBtnId: '#openImageModalBtn',
            closeModalBtnId: '#closeImageModalBtn',
            linkImageThumpInputId: '#link_image_thump', // ID của input ẩn
            fileInputId: '#input_img', // ID của input type="file"
            ajaxUrl: '<?php echo base_url('upload/get_images_paginated'); ?>', // URL AJAX để lấy ảnh
            imagesPerPage: 60, // Số ảnh mỗi trang
            targetImageReviewIds: ['noimage_review', 'image_review'] // Thứ tự ưu tiên các ID thẻ img xem trước
        };

        const myImageSelector = new ImageSelector(imageSelectorOptions);
    });
</script>

</body>
</html>
---

Hướng Dẫn Sử Dụng

  1. Cấu hình CodeIgniter: Đảm bảo base_url trong application/config/config.php đã được thiết lập chính xác.
  2. Tạo thư mục upload: Tạo thư mục upload trong thư mục gốc của dự án CodeIgniter và đảm bảo nó có quyền ghi. Đặt các ảnh vào đây (có thể cả trong thư mục con) để kiểm tra.
  3. Dán mã PHP: Thay thế nội dung của application/controllers/Upload.php bằng mã PHP đã cung cấp.
  4. Tạo/Cập nhật View: Tạo hoặc cập nhật file application/views/upload_view_custom_modal_paginated.php bằng toàn bộ mã HTML, CSS và JavaScript được cung cấp.
  5. Truy cập: Mở trình duyệt và truy cập http://localhost/your_ci_project/upload (thay your_ci_project bằng tên thư mục dự án của bạn).
---

Lưu ý khi tích hợp vào CKEditor:

  • CKEditor Plugin: Để tích hợp trực tiếp với CKEditor (ví dụ, khi nhấn nút "Image" trong editor), bạn sẽ cần phát triển một CKEditor plugin tùy chỉnh. Plugin này sẽ gọi modal của bạn thay vì trình duyệt file mặc định của CKEditor.
  • Truyền URL về CKEditor: Sau khi người dùng chọn ảnh từ modal của bạn, bạn sẽ cần một cách để truyền URL của ảnh đã chọn trở lại CKEditor. Thông thường, các plugin CKEditor sử dụng hàm callback CKEDITOR.tools.callFunction() để gửi dữ liệu trở lại.
  • Input ẩn (link_image_thump): Input này rất hữu ích khi bạn gửi form chính chứa CKEditor. Server của bạn có thể đọc giá trị của link_image_thump để biết người dùng đã chọn ảnh nào từ thư viện.
Chúc bạn thành công với việc tích hợp này!