/* Nút cố định "Bắt đầu tạo hình" */ .fixed-btn { position: fixed; bottom: 30px; right: 30px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; padding: 15px 30px; font-size: 16px; font-weight: bold; border-radius: 50px; cursor: pointer; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); z-index: 1000; transition: all 0.3s ease; } .fixed-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); } .fixed-btn:active { transform: translateY(0); } .fixed-btn.loading { opacity: 0.85; cursor: wait; } /* Nút minimized gallery */ .minimized-gallery-btn { bottom: 30px; right: 30px; background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); z-index: 1500; /* Cao hơn fixed-btn để hiển thị trên nút "Bắt đầu tạo hình" */ } .minimized-gallery-btn.hidden { display: none; } /* Modal Hướng dẫn */ .guide-modal-content { max-width: 800px; max-height: 90vh; } .guide-modal-body { padding: 20px; overflow-y: auto; max-height: calc(90vh - 100px); } .guide-content { white-space: pre-wrap; font-family: 'Arial', sans-serif; font-size: 14px; line-height: 1.6; color: #333; background: white; padding: 15px; border-radius: 4px; border: 1px solid #ddd; } /* Modal Thư viện */ .library-modal-content { max-width: 900px; max-height: 90vh; } .library-modal-body { padding: 20px; overflow-y: auto; max-height: calc(90vh - 100px); } .library-content { position: relative; } .library-loading { text-align: center; padding: 40px; font-size: 16px; color: #666; } .library-empty { text-align: center; padding: 40px; font-size: 16px; color: #999; } .library-images-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; padding: 10px 0; } .library-image-item { position: relative; border: 2px solid #ddd; border-radius: 8px; overflow: hidden; cursor: pointer; transition: all 0.3s ease; background: #f5f5f5; aspect-ratio: 1; } .library-image-item:hover { border-color: #667eea; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .library-image-item img { width: 100%; height: 100%; object-fit: cover; display: block; } .library-image-item .image-name { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0, 0, 0, 0.7); color: white; padding: 8px; font-size: 12px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .library-empty.hidden, .library-loading.hidden { display: none; } /* Dialog xác nhận "Bắt đầu" */ .confirm-modal-content { max-width: 450px; padding: 0; } .confirm-dialog { display: flex; flex-direction: column; align-items: center; padding: 30px; text-align: center; } .confirm-icon { font-size: 64px; margin-bottom: 20px; color: #4A90E2; } .confirm-content { margin-bottom: 30px; } .confirm-title { font-size: 20px; font-weight: bold; margin: 0 0 15px 0; color: #333; } .confirm-message { font-size: 14px; line-height: 1.6; color: #666; margin: 0; white-space: pre-line; } .confirm-buttons { display: flex; gap: 12px; justify-content: center; width: 100%; } .confirm-buttons .btn { min-width: 100px; padding: 10px 20px; } /* Dialog tải DXF (giống desktop app) */ .dxf-modal-content { max-width: 400px; padding: 0; } .dxf-dialog { display: flex; flex-direction: column; } .dxf-dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid #e0e0e0; } .dxf-dialog-header h3 { margin: 0; font-size: 16px; font-weight: bold; color: #333; } .dxf-dialog-body { padding: 20px; display: flex; flex-direction: column; } .dxf-info-label { font-size: 9pt; color: #666; margin-bottom: 15px; line-height: 1.4; } .dxf-input-frame { display: flex; align-items: center; margin-bottom: 10px; width: 100%; } .dxf-input-frame:last-of-type { margin-bottom: 15px; } .dxf-label { width: 100px; min-width: 100px; text-align: left; padding-right: 5px; font-size: 14px; color: #333; } .dxf-entry { flex: 1; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; width: 100%; max-width: 240px; } .dxf-entry:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); } .dxf-dialog-buttons { display: flex; gap: 12px; justify-content: flex-end; margin-top: 10px; } .dxf-dialog-buttons .btn { min-width: 100px; padding: 8px 20px; font-size: 14px; } /* Modal overlay */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); display: flex; justify-content: center; align-items: center; z-index: 2000; animation: fadeIn 0.3s ease; } .modal.hidden { display: none; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } /* Modal content */ .modal-content { background: white; border-radius: 15px; width: 90%; max-width: 900px; /* Tăng từ 800px để gallery có nhiều không gian hơn */ max-height: 95vh; /* Tăng từ 90vh để gallery có nhiều không gian hơn */ overflow-y: auto; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); animation: slideUp 0.3s ease; /* Chỉ có 1 scrollbar duy nhất ở đây */ } @keyframes slideUp { from { transform: translateY(50px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } /* Modal header */ .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 30px; border-bottom: 2px solid #f0f0f0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 15px 15px 0 0; } .modal-header h2 { margin: 0; font-size: 24px; } .modal-header-buttons { display: flex; gap: 10px; align-items: center; } .minimize-btn { background: rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.3); color: white; font-size: 24px; font-weight: bold; cursor: pointer; padding: 0; width: 36px; height: 36px; border-radius: 6px; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; } .minimize-btn:hover { background: rgba(255, 255, 255, 0.3); transform: scale(1.1); } .close-btn { background: none; border: none; color: white; font-size: 32px; cursor: pointer; padding: 0; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background 0.3s ease; } .close-btn:hover { background: rgba(255, 255, 255, 0.2); } /* Form view */ .form-view { padding: 30px; } .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: #333; } .form-group input[type="number"], .form-group input[type="text"], .form-group select { width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; transition: border-color 0.3s ease; } .form-group input[type="number"]:focus, .form-group input[type="text"]:focus, .form-group select:focus { outline: none; border-color: #667eea; } .form-group input[type="checkbox"] { width: auto; margin-right: 8px; } .dialog-heading { text-align: center; margin-bottom: 18px; } .dialog-heading h3 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: 0.5px; } .dialog-subtitle { margin: 8px 0 0; font-size: 14px; color: #3a63d4; line-height: 1.5; } .form-section { border: 1px solid #e0e0e0; border-radius: 10px; padding: 18px; margin-bottom: 18px; background: #ffffff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); } .section-title { font-size: 14px; font-weight: 700; color: #333; margin-bottom: 14px; } .section-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .section-row label { min-width: 120px; margin: 0; font-weight: 600; color: #333; } .section-row input[type="text"], .section-row input[type="number"], .section-row select { flex: 1; } .section-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 18px; } .field label { display: block; margin-bottom: 6px; font-weight: 600; color: #333; } .inline-field { display: flex; align-items: center; gap: 10px; } .inline-field select, .inline-field input { flex: 1; } .unit { font-size: 13px; color: #666; } .help-text { margin: 12px 0 0 0; font-size: 12px; color: #2b61d8; line-height: 1.4; } .help-inline { font-size: 12px; color: #666; } .size-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 18px; } .size-item label { font-weight: 600; color: #333; display: block; margin-bottom: 6px; } .size-input { display: flex; align-items: center; gap: 8px; } .size-item select { width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 7px; font-size: 14px; transition: border-color 0.3s ease; } .size-item select:focus { outline: none; border-color: #667eea; } .size-unit { font-size: 13px; color: #666; font-weight: 500; white-space: nowrap; } .radio-group { display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 10px; background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 8px; } .radio-group label { display: flex; align-items: center; gap: 6px; font-weight: 500; color: #444; } .radio-group input[type="radio"] { accent-color: #667eea; transform: scale(1.1); } .type-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; } .type-grid { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; } .checkbox-inline { display: flex; align-items: center; gap: 8px; font-weight: 600; color: #444; } .checkbox-inline input[type="checkbox"] { transform: scale(1.1); accent-color: #667eea; } .count-grid { display: grid; grid-template-columns: auto minmax(150px, 190px) 1fr; column-gap: 16px; row-gap: 12px; align-items: center; } .count-label { font-weight: 600; color: #333; } .count-field { display: flex; align-items: center; gap: 10px; } .count-field input { width: 80px; } .progress-wrapper { justify-self: stretch; } .progress-wrapper .progress-container { margin-top: 0; padding: 0; background: transparent; border-radius: 0; } .progress-wrapper .progress-bar { height: 22px; } .progress-wrapper .progress-text { font-size: 12px; text-align: right; margin-top: 6px; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .form-actions { margin-top: 25px; padding-top: 20px; border-top: 2px solid #f0f0f0; } /* Buttons */ .btn { padding: 12px 30px; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; } .btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .btn-secondary { background: #e0e0e0; color: #333; } .btn-secondary:hover { background: #d0d0d0; } .btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; } .btn-tertiary { background: #f2f2f2; color: #444; cursor: pointer; pointer-events: auto; } .btn-tertiary:hover:not(:disabled) { background: #e4e4e4; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .btn-tertiary:disabled { opacity: 0.6; cursor: not-allowed; pointer-events: none; } .action-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, auto)); gap: 12px; align-items: center; justify-content: center; margin-bottom: 12px; } .btn-full { width: 100%; } /* Progress bar */ .progress-container { margin-top: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px; } .progress-bar { width: 100%; height: 30px; background: #e0e0e0; border-radius: 15px; overflow: hidden; margin-bottom: 10px; } .progress-fill { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); width: 0%; transition: width 0.3s ease; border-radius: 15px; } .progress-text { text-align: center; color: #666; font-size: 14px; margin: 0; } .hidden { display: none !important; } .global-progress { position: fixed; left: 0; right: 0; bottom: 0; background: rgba(34, 40, 49, 0.92); color: #fff; padding: 10px 24px; box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.25); z-index: 2500; } .global-progress-inner { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 4px; } #global-progress-text { font-size: 14px; font-weight: 600; letter-spacing: 0.3px; } .global-progress-bar { width: 100%; height: 9px; background: rgba(255, 255, 255, 0.18); border-radius: 999px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.25); } .global-progress-bar > div { height: 100%; width: 0%; background: linear-gradient(90deg, #6a9dfc 0%, #8d6bfc 100%); border-radius: 999px; transition: width 0.3s ease; } /* Gallery view */ .gallery-view { padding: 20px; /* Bỏ max-height và overflow-y vì modal-content đã có scrollbar */ } .gallery-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 2px solid #f0f0f0; } .gallery-header h3 { margin: 0; font-size: 22px; color: #333; } .gallery-body { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } /* Canvas container với nút điều hướng hai bên */ .gallery-canvas-container { display: flex; align-items: center; gap: 10px; width: 100%; position: relative; min-height: 400px; } .gallery-nav-btn-side { background: #f2f2f2; border: none; border-radius: 6px; padding: 10px 14px; font-size: 14px; cursor: pointer; transition: all 0.2s ease; font-weight: 600; color: #444; white-space: nowrap; align-self: center; } .gallery-nav-btn-side:hover:not(:disabled) { background: #e0e0e0; } .gallery-nav-btn-side:disabled { opacity: 0.5; cursor: not-allowed; } .gallery-canvas-wrapper { flex: 1; background: white; border: 1px solid #ccc; border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; min-height: 350px; height: 400px; /* Giảm từ 500px xuống 400px */ position: relative; width: 100%; } #gallery-main-canvas { max-width: 100%; max-height: 100%; cursor: grab; } #gallery-main-canvas:active { cursor: grabbing; } /* Zoom controls */ .gallery-zoom-controls { display: flex; align-items: center; gap: 5px; padding: 5px 0; font-size: 12px; } .zoom-label { font-size: 12px; margin-right: 3px; } .zoom-btn-small { width: 24px; height: 24px; border: 1px solid #ccc; border-radius: 3px; background: white; cursor: pointer; font-size: 14px; font-weight: bold; padding: 0; display: flex; align-items: center; justify-content: center; } .zoom-btn-small:hover { background: #f0f0f0; } .zoom-level-text { font-size: 12px; width: 40px; text-align: center; margin: 0 3px; } .zoom-spacer { width: 20px; } /* Label số hình hiện tại */ .gallery-image-label { text-align: center; padding: 2px 0; font-size: 14px; color: #333; } /* Thông tin tranh và nút hành động */ .gallery-info-actions { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; gap: 10px; } .gallery-info-text { flex: 1; font-size: 12px; color: #333; } .gallery-action-buttons { display: flex; gap: 5px; } .btn-action { padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; transition: all 0.2s ease; } .btn-action:hover { background: #f0f0f0; } .gallery-select-button { display: flex; justify-content: center; margin: 2px 0 0 0; padding: 0; } .btn-select { background: #4CAF50; color: white; border: 2px solid #2d7a32; border-radius: 6px; padding: 6px 12px; font-size: 8px; font-weight: bold; cursor: pointer; line-height: 1.2; text-align: center; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); transition: all 0.2s ease; } .btn-select:hover { background: #45a049; box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); } .btn-select:active { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); transform: translateY(1px); } .gallery-basic-info { display: flex; justify-content: center; padding: 10px; font-size: 10px; color: #333; } .gallery-thumbnails { border-top: 2px solid #f0f0f0; padding-top: 12px; } .thumbnail-list { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; } .thumbnail-item { border: 2px solid transparent; border-radius: 10px; padding: 4px; background: white; cursor: pointer; transition: all 0.2s ease; } .thumbnail-item img { width: 72px; height: 72px; object-fit: contain; border-radius: 6px; background: #f4f4f4; } .thumbnail-item.active { border-color: #6a9dfc; box-shadow: 0 0 0 3px rgba(106, 157, 252, 0.25); } .thumbnail-item:hover { border-color: #90b5ff; } /* Responsive */ @media (max-width: 768px) { .modal-content { width: 95%; max-height: 95vh; } .size-grid { grid-template-columns: 1fr; } .size-unit { margin-left: 0; } .section-row { flex-direction: column; align-items: flex-start; } .section-row label { min-width: auto; } .type-row, .type-grid { flex-direction: column; align-items: flex-start; gap: 12px; } .count-grid { grid-template-columns: 1fr; } .action-row { grid-template-columns: 1fr; justify-items: stretch; } .gallery-body { flex-direction: column; } .gallery-main { min-width: auto; flex: 1; } .gallery-info-panel { flex: 1; width: 100%; } } /* Cửa sổ "Hình Đã Chọn" */ .selected-window { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); display: flex; justify-content: center; align-items: center; z-index: 3000; animation: fadeIn 0.3s ease; } .selected-window-content { background: white; border-radius: 12px; width: 900px; max-width: 95vw; max-height: 95vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); } .selected-window-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 2px solid #e0e0e0; } .selected-window-header h3 { margin: 0; font-size: 16px; font-weight: bold; color: #333; } .selected-window-body { padding: 10px; overflow-y: auto; flex: 1; } .selected-canvas-container { width: 100%; height: 500px; border: 1px solid #e0e0e0; background: white; margin-bottom: 5px; overflow: auto; } #selected-image-canvas { display: block; margin: 0 auto; } .selected-zoom-controls { display: flex; align-items: center; gap: 5px; padding: 5px 0; font-size: 8px; } .zoom-btn { width: 24px; height: 24px; border: 1px solid #ccc; background: #f5f5f5; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: bold; } .zoom-btn:hover { background: #e0e0e0; } .zoom-spacer { width: 10px; } .btn-view-gif, .btn-view-image { padding: 6px 12px; border: 2px solid #4A90E2; border-radius: 4px; font-size: 9px; font-weight: bold; cursor: pointer; background: #6BA3D6; color: black; transition: all 0.2s ease; } .btn-view-gif:hover:not(:disabled), .btn-view-image:hover:not(:disabled) { background: #5C9BD1; } .btn-view-gif:disabled, .btn-view-image:disabled { background: #9BC4E2; color: darkgray; cursor: not-allowed; } .selected-info-frame { padding: 10px; font-size: 10px; color: #333; } .selected-actions-frame { display: flex; justify-content: flex-end; gap: 5px; padding: 10px; border-top: 1px solid #e0e0e0; } .btn-action { padding: 8px 15px; border: 1px solid #ccc; border-radius: 4px; background: #f5f5f5; color: #333; font-size: 12px; cursor: pointer; transition: all 0.2s ease; } .btn-action:hover { background: #e0e0e0; }

String Art

About us

Product

Blog

Contact

String Art

Buy Now

NGHỆ THUẬT DÂY ĐỘC ĐÁO

Chọn từ: Mẫu có sẵn, Tác phẩm nghệ thuật hoàn thiện & Thiết kế theo yêu cầu

VỀ CHÚNG TÔI

"Yêu cái đẹp là thưởng thức. Tạo ra cái đẹp là nghệ thuật." Ralph Waldo Emerson.
String Art - #nghệ_thuật_chuỗi: Các đường cong được tạo nên từ chuỗi các đường thẳng đi theo một tỷ lệ định sẵn trước kết hợp với các màu sắc tạo nên một loại hình nghệ thuật độc đáo.
Từ những sợi dây những cây đinh, nguyên vật liệu đơn giản dưới sự kết hợp tài tình của phép tỷ lệ đã tạo nên một bức tranh với nhiều màu sắc độc đáo, mang đậm tính chất số học.

  • Sản phẩm làm thủ công với độ chính xác cao, tỉ mỉ.
  • Vật liệu đinh được chọn lựa đều đẹp.
  • Sợi dây bóng đẹp đặc biệt không dính bụi.
3 BƯỚC ĐỂ TẠO RA TÁC PHẨM NGHỆ THUẬT THEO YÊU CẦU CỦA BẠN 🎨
Bước 1: Chọn thiết kế của bạn
Duyệt qua thư viện ảnh của chúng tôi hoặc tải ảnh của riêng bạn lên để tạo ra một tác phẩm độc đáo.
Bước 2: Xem trước & Hoàn thiện
Nhận bản xem trước trong vòng 24 giờ, với số lần sửa đổi không giới hạn và đảm bảo hài lòng 100%.
Bước 3: Nhận và sáng tạo
Mở hộp bộ sản phẩm, bắt đầu chế tạo và tự hào trưng bày kiệt tác hoàn thiện của bạn!
Bạn lo lắng về diện mạo cuối cùng?
Hãy thử xem trước tác phẩm nghệ thuật miễn phí! 🤩

Thử ngay

BST THU ĐÔNG 2024

ƯU ĐÃI GIỮA ĐÔNG

SAV01

400.000đ

160.000đ

-60%

[Title]

[Variant]

4.9
6.520 đánh giá
30.314 đã bán
Số lượng
1.701 sản phẩm có sẵn
+
Mẫu có sẵn

Thêm vào giỏ hàng

Mua ngay

SAV01 - Nghệ thuật chuỗi - Sunrise

  • Kích thước: 60x60cm
  • Chất liệu: Gỗ, đinh, giây màu
  • Thiết kế hoàn toàn thủ công

BST THU ĐÔNG 2024

ƯU ĐÃI GIỮA ĐÔNG

SAV02

400.000đ

160.000đ

-60%

[Title]

[Variant]

4.9
6.520 đánh giá
30.314 đã bán
Số lượng
1.701 sản phẩm có sẵn
+
Mẫu có sẵn

Thêm vào giỏ hàng

Mua ngay

SAV02 - Universe

Kích thước: 60x60cm

Chất liệu: Gỗ, đinh, giây màu

Sản phẩm thủ công

BST THU ĐÔNG 2024

ƯU ĐÃI GIỮA ĐÔNG

SAV03

400.000đ

160.000đ

-60%

[Title]

[Variant]

4.9
6.520 đánh giá
30.314 đã bán
Số lượng
1.701 sản phẩm có sẵn
+
Mẫu có sẵn

Thêm vào giỏ hàng

Mua ngay

SAV03 - Nghệ thuật chuỗi

  • Kích thước: 60x60cm
  • Chất liệu: Gỗ, đinh, giây màu
  • Thiết kế hoàn toàn thủ công

DUYỆT QUA THƯ VIỆN ĐỂ CÓ THÊM CẢM HỨNG

Chọn từ: Mẫu có sẵn, Tác phẩm nghệ thuật hoàn thiện & Thiết kế theo yêu cầu

TẠI SAO CHỌN CHÚNG TÔI?

Sửa đổi không giới hạn
Nhận bản xem trước qua email và yêu cầu thay đổi nhiều lần cho đến khi bạn hài lòng 100%.
Miễn phí vận chuyển
Tận hưởng dịch vụ giao hàng tiêu chuẩn miễn phí với quy trình xử lý nhanh chóng - đơn hàng sẽ được giao trong vòng 1-2 ngày!
Vật liệu cao cấp
Được chế tác bằng vật liệu chất lượng cao để tạo nên kiệt tác bền vững.
Hỗ trợ khách hàng nhanh chóng
Bạn cần trợ giúp? Chúng tôi luôn sẵn sàng hỗ trợ bạn.
Vật liệu được tuyển chọn nghiêm ngặt, đảm bảo chất lượng, chắc chắn và tính thẩm mỹ
Thực hiện hoàn toàn thủ công bởi những người thợ giàu kinh nghiệm trong mỹ thuật, không gian và vật lý
Thiết kế thanh lịch, tinh tế, tác động thị giác ấn tượng. Chúng tôi tối ưu hóa mọi ảnh để đảm bảo tác phẩm nghệ thuật bằng dây của bạn trông tuyệt đẹp, với tác phẩm cuối cùng hoàn toàn khớp với bản xem trước.
Trải nghiệm sản phẩm của khách hàng, từ 319 đánh giá

Đánh giá của khách hàng

DỊCH VỤ KHÁCH HÀNG

Chính sách vận chuyển

Artboard 26

Hướng dẫn & Câu hỏi thường gặp

Chính sách bảo mật

http://thenounproject.comThe Noun ProjectIcon TemplateRemindersStrokesTry to keep strokes at 4pxMinimum stroke weight is 2pxFor thicker strokes use even numbers: 6px, 8px etc.Remember to expand strokes before saving as an SVG SizeCannot be wider or taller than 100px (artboard size)Scale your icon to fill as much of the artboard as possibleUngroupIf your design has more than one shape, make sure to ungroupSave asSave as .SVG and make sure “Use Artboards” is checked100px.SVG

Liên hệ với chúng tôi

STRING ART VIETNAM - NGHỆ THUẬT CHUỖI

Address: Thu Duc, Ho Chi Minh

Hotline: 096 126 97 87

Email: vietnamstringart@gmail.com

Website: www.stringartvn.com

©2025 Allrights reserved stringartvn.com

Chuyển khoản
ngân hàng

🇻🇳 Việt Nam (VNĐ)

Trở lại đầu trang

About us

Product

Blog

Contact

String Art

/** * JavaScript xử lý modal và gọi API Backend */ // Cấu hình API // THAY ĐỔI URL NÀY THÀNH URL BACKEND THỰC TẾ CỦA BẠN // Ví dụ: const API_BASE_URL = '/api'; (nếu Backend chạy trên cùng domain) // Hoặc: const API_BASE_URL = 'https://api.stringartvn.com/api'; (nếu Backend chạy trên domain khác) const API_BASE_URL = 'https://api.stringartvn.com/api'; // Giá trị mặc định (sẽ được load từ settings) let defaultSettings = { paper_size: '400', bg_shape: 'vuông', bg_color: 'đen', pen_color: 'đỏ', count: 1, shape_type: 'symmetric' }; // Settings từ backend (bao gồm danh sách options) let settingsData = null; // DOM Elements const startArtBtn = document.getElementById('start-art-btn'); const artModal = document.getElementById('art-modal'); const closeModalBtn = document.getElementById('close-modal-btn'); const cancelBtn = document.getElementById('cancel-btn'); const artForm = document.getElementById('art-form'); const inputForm = document.getElementById('input-form'); const galleryView = document.getElementById('gallery-view'); const progressContainer = document.getElementById('progress-container'); const progressFill = document.getElementById('progress-fill'); const progressText = document.getElementById('progress-text'); const globalProgress = document.getElementById('global-progress'); const globalProgressFill = document.getElementById('global-progress-fill'); const globalProgressText = document.getElementById('global-progress-text'); const galleryTitle = document.getElementById('gallery-title'); const galleryMainCanvas = document.getElementById('gallery-main-canvas'); const galleryPrevBtn = document.getElementById('gallery-prev-btn'); const galleryNextBtn = document.getElementById('gallery-next-btn'); const galleryInfoText = document.getElementById('gallery-info-text'); const galleryImageLabelText = document.getElementById('gallery-image-label-text'); const galleryThumbnails = document.getElementById('gallery-thumbnails'); const newArtBtn = document.getElementById('new-art-btn'); const galleryZoomInBtn = document.getElementById('gallery-zoom-in-btn'); const galleryZoomOutBtn = document.getElementById('gallery-zoom-out-btn'); const galleryZoomResetBtn = document.getElementById('gallery-zoom-reset-btn'); const galleryZoomLevelText = document.getElementById('gallery-zoom-level-text'); const galleryViewGifBtn = document.getElementById('gallery-view-gif-btn'); const galleryViewImageBtn = document.getElementById('gallery-view-image-btn'); const galleryDxfBtn = document.getElementById('gallery-dxf-btn'); const galleryCartBtn = document.getElementById('gallery-cart-btn'); const minimizeModalBtn = document.getElementById('minimize-modal-btn'); const minimizedGalleryBtn = document.getElementById('minimized-gallery-btn'); const DEFAULT_START_BUTTON_TEXT = startArtBtn ? startArtBtn.textContent : '🎨 Bắt đầu tạo hình'; // State let currentSessionId = null; let galleryImages = []; let currentGalleryIndex = 0; let currentSelectedImageIndex = null; let currentZoomLevel = 0.25; // Giảm từ 0.3 xuống 0.25 (25%) để khớp với desktop app let currentImageObj = null; let canvasCtx = null; let galleryCanvasCtx = null; // Context cho gallery main canvas let isGifMode = false; let animationFrameId = null; let currentPathData = null; // Biến cho drag/pan let isDragging = false; let dragStartX = 0; let dragStartY = 0; let imageOffsetX = 0; let imageOffsetY = 0; // Load settings từ backend khi khởi tạo async function loadSettings() { try { const response = await fetch(`${API_BASE_URL}/settings`); if (response.ok) { const result = await response.json(); if (result.success) { settingsData = result.settings; defaultSettings = result.defaults; console.log('Settings loaded:', settingsData); console.log('Defaults loaded:', defaultSettings); // Populate options từ settings populateOptionsFromSettings(); // Áp dụng giá trị mặc định vào form applyDefaultValues(); } } } catch (err) { console.error('Error loading settings:', err); // Vẫn dùng giá trị mặc định hardcode nếu không load được applyDefaultValues(); } } // Populate options trong select từ settings function populateOptionsFromSettings() { if (!settingsData) return; // Populate paper_size options const paperSizeSelect = document.getElementById('paper-size'); if (paperSizeSelect && settingsData.paper_sizes) { // Xóa tất cả options hiện tại paperSizeSelect.innerHTML = ''; // Thêm options từ settings settingsData.paper_sizes.forEach(size => { const option = document.createElement('option'); option.value = size; option.textContent = size; paperSizeSelect.appendChild(option); }); } // Populate bg_shape options const bgShapeSelect = document.getElementById('bg-shape'); if (bgShapeSelect && settingsData.shapes) { bgShapeSelect.innerHTML = ''; settingsData.shapes.forEach(shape => { const option = document.createElement('option'); option.value = shape; option.textContent = shape.charAt(0).toUpperCase() + shape.slice(1); // Capitalize first letter bgShapeSelect.appendChild(option); }); } // Populate bg_color options const bgColorSelect = document.getElementById('bg-color'); if (bgColorSelect && settingsData.bg_colors) { bgColorSelect.innerHTML = ''; settingsData.bg_colors.forEach(color => { const option = document.createElement('option'); option.value = color; option.textContent = color.charAt(0).toUpperCase() + color.slice(1); bgColorSelect.appendChild(option); }); } // Populate pen_color options const penColorSelect = document.getElementById('pen-color'); if (penColorSelect && settingsData.pen_colors) { penColorSelect.innerHTML = ''; settingsData.pen_colors.forEach(color => { const option = document.createElement('option'); option.value = color; option.textContent = color.charAt(0).toUpperCase() + color.slice(1); penColorSelect.appendChild(option); }); } // Update max count cho count input const countInput = document.getElementById('count'); if (countInput && settingsData.current_max_count) { countInput.max = settingsData.current_max_count; const helpInline = countInput.parentElement.querySelector('.help-inline'); if (helpInline) { helpInline.textContent = `(1-${settingsData.current_max_count})`; } } } // Áp dụng giá trị mặc định vào form function applyDefaultValues() { const paperSizeSelect = document.getElementById('paper-size'); const bgShapeSelect = document.getElementById('bg-shape'); const bgColorSelect = document.getElementById('bg-color'); const penColorSelect = document.getElementById('pen-color'); const countInput = document.getElementById('count'); const shapeTypeRadios = document.querySelectorAll('input[name="shape_type"]'); if (paperSizeSelect && defaultSettings.paper_size) { paperSizeSelect.value = defaultSettings.paper_size; } if (bgShapeSelect && defaultSettings.bg_shape) { bgShapeSelect.value = defaultSettings.bg_shape; } if (bgColorSelect && defaultSettings.bg_color) { bgColorSelect.value = defaultSettings.bg_color; } if (penColorSelect && defaultSettings.pen_color) { penColorSelect.value = defaultSettings.pen_color; } if (countInput && defaultSettings.count) { countInput.value = defaultSettings.count; } if (shapeTypeRadios && defaultSettings.shape_type) { shapeTypeRadios.forEach(radio => { if (radio.value === defaultSettings.shape_type) { radio.checked = true; } }); } } // ============================================ // Logic loại trừ lẫn nhau: Fibonacci và Loại hình // ============================================ // Xử lý khi thay đổi checkbox Fibonacci function setupFibonacciLogic() { const fibonacciCheckbox = document.getElementById('use-fibonacci'); const shapeTypeRadios = document.querySelectorAll('input[name="shape_type"]'); if (!fibonacciCheckbox || shapeTypeRadios.length === 0) { return; // Chưa có elements, bỏ qua } // Xóa listeners cũ nếu có (bằng cách clone và replace) if (fibonacciCheckbox.hasAttribute('data-fibonacci-listener')) { return; // Đã setup rồi, không setup lại } // Handler cho Fibonacci checkbox function onFibonacciChange() { if (this.checked) { // Khi tick Fibonacci, bỏ chọn tất cả radio button shapeTypeRadios.forEach(radio => { radio.checked = false; }); console.log('✓ Fibonacci được chọn - bỏ chọn đối xứng/không đối xứng/cả hai'); } else { // Khi bỏ tick Fibonacci, chọn mặc định "symmetric" const symmetricRadio = document.querySelector('input[name="shape_type"][value="symmetric"]'); if (symmetricRadio) { symmetricRadio.checked = true; } console.log('✓ Fibonacci bỏ chọn - chọn mặc định đối xứng'); } } // Handler cho radio buttons function onShapeTypeChange() { // Khi chọn 1 trong 3 radio button, bỏ chọn Fibonacci if (fibonacciCheckbox && fibonacciCheckbox.checked) { fibonacciCheckbox.checked = false; console.log(`✓ Chọn ${this.value} - bỏ chọn Fibonacci`); } } // Thêm event listeners fibonacciCheckbox.addEventListener('change', onFibonacciChange); fibonacciCheckbox.setAttribute('data-fibonacci-listener', 'true'); shapeTypeRadios.forEach(radio => { if (!radio.hasAttribute('data-shape-listener')) { radio.addEventListener('change', onShapeTypeChange); radio.setAttribute('data-shape-listener', 'true'); } }); } // Event Listeners startArtBtn.addEventListener('click', () => { showModal(); showInputForm(); }); // Load settings khi trang được tải loadSettings(); // Setup logic loại trừ lẫn nhau cho Fibonacci và Loại hình setupFibonacciLogic(); // Khởi tạo confirm dialog khi DOM sẵn sàng if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initConfirmDialog); } else { initConfirmDialog(); } closeModalBtn.addEventListener('click', () => { hideModal(); // Ẩn nút minimized khi đóng hoàn toàn if (minimizedGalleryBtn) { minimizedGalleryBtn.classList.add('hidden'); } }); cancelBtn.addEventListener('click', () => { hideModal(); // Ẩn nút minimized khi đóng hoàn toàn if (minimizedGalleryBtn) { minimizedGalleryBtn.classList.add('hidden'); } }); // Nút minimize if (minimizeModalBtn) { minimizeModalBtn.addEventListener('click', () => { minimizeModal(); }); } // Nút minimized gallery if (minimizedGalleryBtn) { minimizedGalleryBtn.addEventListener('click', () => { restoreModal(); }); } // KHÔNG đóng modal khi click ra ngoài (theo yêu cầu) // artModal.addEventListener('click', (e) => { // if (e.target === artModal) { // hideModal(); // } // }); if (newArtBtn) { newArtBtn.addEventListener('click', () => { // Khi nhấn "Tạo hình mới": đóng gallery và mở lại form nhập liệu // Modal vẫn mở, chỉ chuyển từ gallery view sang input form showInputForm(); // Ẩn nút minimized if (minimizedGalleryBtn) { minimizedGalleryBtn.classList.add('hidden'); } // Đảm bảo modal được hiển thị showModal(); }); } if (galleryPrevBtn) { galleryPrevBtn.addEventListener('click', () => navigateGallery(-1)); } if (galleryNextBtn) { galleryNextBtn.addEventListener('click', () => navigateGallery(1)); } // Event listeners cho gallery zoom controls if (galleryZoomInBtn) { galleryZoomInBtn.addEventListener('click', () => zoomGalleryCanvas(1.1)); } if (galleryZoomOutBtn) { galleryZoomOutBtn.addEventListener('click', () => zoomGalleryCanvas(1/1.1)); } if (galleryZoomResetBtn) { galleryZoomResetBtn.addEventListener('click', () => resetGalleryZoom()); } if (galleryViewGifBtn) { galleryViewGifBtn.addEventListener('click', () => switchToGifMode()); } if (galleryViewImageBtn) { galleryViewImageBtn.addEventListener('click', () => switchToImageMode()); } if (galleryDxfBtn) { galleryDxfBtn.addEventListener('click', () => { if (galleryImages[currentGalleryIndex]) { downloadDXF(galleryImages[currentGalleryIndex].index); } }); } if (galleryCartBtn) { galleryCartBtn.addEventListener('click', () => { if (galleryImages[currentGalleryIndex]) { addToCart(galleryImages[currentGalleryIndex].index); } }); } // Hàm thêm vào giỏ hàng function addToCart(imageIndex) { alert('Tính năng giỏ hàng chưa khả dụng trên phiên bản web.\nHình #' + imageIndex + ' sẽ được thêm vào giỏ hàng khi tính năng được triển khai.'); } function setStartButtonLoading(isLoading) { if (!startArtBtn) return; if (isLoading) { startArtBtn.textContent = '⏳ Đang tạo hình...'; startArtBtn.classList.add('loading'); startArtBtn.disabled = true; } else { startArtBtn.textContent = DEFAULT_START_BUTTON_TEXT; startArtBtn.classList.remove('loading'); startArtBtn.disabled = false; } } // ============================================ // Dialog xác nhận "Bắt đầu" // ============================================ // Khởi tạo các element khi DOM sẵn sàng let confirmStartModal = null; let confirmMessage = null; let confirmOkBtn = null; let confirmCancelBtn = null; function initConfirmDialog() { confirmStartModal = document.getElementById('confirm-start-modal'); confirmMessage = document.getElementById('confirm-message'); confirmOkBtn = document.getElementById('confirm-ok-btn'); confirmCancelBtn = document.getElementById('confirm-cancel-btn'); if (confirmStartModal && confirmMessage && confirmOkBtn && confirmCancelBtn) { console.log('Confirm dialog elements initialized'); } else { console.warn('Some confirm dialog elements not found:', { modal: !!confirmStartModal, message: !!confirmMessage, okBtn: !!confirmOkBtn, cancelBtn: !!confirmCancelBtn }); } } // Hiển thị dialog xác nhận function showConfirmDialog(luckyNumber, count) { return new Promise((resolve) => { // Khởi tạo lại nếu chưa có if (!confirmStartModal || !confirmMessage || !confirmOkBtn || !confirmCancelBtn) { initConfirmDialog(); } if (!confirmStartModal || !confirmMessage) { console.warn('Confirm dialog not found, proceeding without confirmation'); // Nếu không có dialog, tiếp tục luôn resolve(true); return; } // Cập nhật nội dung message confirmMessage.textContent = `Đang tạo ${count} tranh từ số ${luckyNumber}...\n\n(Thời gian tạo mất khoảng 1 phút, xin vui lòng chờ)\n\nNhấn OK để bắt đầu hoặc Hủy để hủy bỏ.`; // Hiển thị modal confirmStartModal.classList.remove('hidden'); console.log('Confirm dialog shown'); // Xử lý nút OK const handleOk = () => { console.log('User clicked OK'); confirmStartModal.classList.add('hidden'); if (confirmOkBtn) confirmOkBtn.removeEventListener('click', handleOk); if (confirmCancelBtn) confirmCancelBtn.removeEventListener('click', handleCancel); if (confirmStartModal) confirmStartModal.removeEventListener('click', handleOutsideClick); resolve(true); }; // Xử lý nút Cancel const handleCancel = () => { console.log('User clicked Cancel'); confirmStartModal.classList.add('hidden'); if (confirmOkBtn) confirmOkBtn.removeEventListener('click', handleOk); if (confirmCancelBtn) confirmCancelBtn.removeEventListener('click', handleCancel); if (confirmStartModal) confirmStartModal.removeEventListener('click', handleOutsideClick); resolve(false); }; // Đóng khi click bên ngoài const handleOutsideClick = (e) => { if (e.target === confirmStartModal) { handleCancel(); } }; if (confirmOkBtn) { confirmOkBtn.addEventListener('click', handleOk); } if (confirmCancelBtn) { confirmCancelBtn.addEventListener('click', handleCancel); } if (confirmStartModal) { confirmStartModal.addEventListener('click', handleOutsideClick); } }); } // Form submit artForm.addEventListener('submit', async (e) => { e.preventDefault(); // Validate form trước khi hiển thị dialog const luckyNumberInput = document.getElementById('lucky-number'); const luckyNumber = luckyNumberInput.value.trim(); if (!luckyNumber) { alert('Vui lòng nhập text hoặc số may mắn của bạn.'); luckyNumberInput.focus(); return; } const countValue = parseInt(document.getElementById('count').value, 10); if (Number.isNaN(countValue) || countValue < 1 || countValue > 10) { alert('Số lượng hình phải nằm trong khoảng 1-10.'); document.getElementById('count').focus(); return; } // Hiển thị dialog xác nhận (giống desktop app) const confirmed = await showConfirmDialog(luckyNumber, countValue); // Nếu người dùng nhấn Hủy hoặc đóng bằng X (result = False), không làm gì if (!confirmed) { return; } // Nếu người dùng nhấn OK, tiếp tục tạo tranh await createArt(); }); // Functions function showModal() { artModal.classList.remove('hidden'); document.body.style.overflow = 'hidden'; // Ngăn scroll background } function hideModal() { artModal.classList.add('hidden'); document.body.style.overflow = ''; // Cho phép scroll lại // Reset form artForm.reset(); currentSessionId = null; // Ẩn nút minimized khi đóng hoàn toàn if (minimizedGalleryBtn) { minimizedGalleryBtn.classList.add('hidden'); } } // Thu nhỏ modal function minimizeModal() { artModal.classList.add('hidden'); // Chỉ hiển thị nút minimized nếu đang ở gallery view (có hình) if (minimizedGalleryBtn && galleryImages.length > 0) { minimizedGalleryBtn.classList.remove('hidden'); } } // Khôi phục modal từ minimized function restoreModal() { artModal.classList.remove('hidden'); // Ẩn nút minimized if (minimizedGalleryBtn) { minimizedGalleryBtn.classList.add('hidden'); } // Đảm bảo gallery được hiển thị nếu đang có hình if (galleryImages.length > 0) { showGallery(); } else { showInputForm(); } } function showInputForm() { inputForm.classList.remove('hidden'); galleryView.classList.add('hidden'); progressContainer.classList.add('hidden'); hideGlobalProgress(); setStartButtonLoading(false); // Reset trạng thái nút và tiến độ giống app sau mỗi lần tạo const submitBtn = document.getElementById('submit-btn'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '🚀 Bắt đầu tạo'; } updateProgress(0, 'Đang tạo tranh...'); if (progressFill) { progressFill.style.width = '0%'; } // Populate lại options từ settings nếu chưa có if (settingsData) { populateOptionsFromSettings(); } // Áp dụng lại giá trị mặc định từ settings khi hiển thị form applyDefaultValues(); // Setup lại logic loại trừ lẫn nhau (vì có thể form được tạo lại) setupFibonacciLogic(); } function showGallery() { inputForm.classList.add('hidden'); galleryView.classList.remove('hidden'); progressContainer.classList.add('hidden'); setStartButtonLoading(false); // Đảm bảo nút minimized được ẩn khi hiển thị gallery (vì modal đang mở) if (minimizedGalleryBtn) { minimizedGalleryBtn.classList.add('hidden'); } } function showProgress() { progressContainer.classList.remove('hidden'); } function showGlobalProgress() { if (globalProgress) { globalProgress.classList.remove('hidden'); } } function hideGlobalProgress() { if (globalProgress) { globalProgress.classList.add('hidden'); } } function updateProgress(percent, text) { progressFill.style.width = `${percent}%`; progressText.textContent = text || `Đang tạo tranh... ${percent}%`; if (globalProgressFill) { globalProgressFill.style.width = `${percent}%`; } if (globalProgressText) { globalProgressText.textContent = text || `Đang tạo tranh... ${percent}%`; } } async function createArt() { try { // Lấy dữ liệu từ form (đã được validate trong form submit handler) const luckyNumberInput = document.getElementById('lucky-number'); const luckyNumber = luckyNumberInput.value.trim(); const countValue = parseInt(document.getElementById('count').value, 10); const paperSizeValue = parseInt(document.getElementById('paper-size').value, 10); const fibonacciChecked = document.getElementById('use-fibonacci').checked; const selectedShapeType = document.querySelector('input[name="shape_type"]:checked'); // Logic giống desktop app: nếu Fibonacci được chọn thì shape_type = "" // Nếu không có Fibonacci, dùng shape_type đã chọn hoặc mặc định "symmetric" let shapeType = 'symmetric'; if (fibonacciChecked) { shapeType = ''; // Fibonacci được chọn, không dùng shape_type } else if (selectedShapeType) { shapeType = selectedShapeType.value; } const formData = { lucky_number: luckyNumber, count: countValue, paper_size: paperSizeValue, shape_type: shapeType, use_fibonacci: fibonacciChecked, bg_color: document.getElementById('bg-color').value, pen_color: document.getElementById('pen-color').value, bg_shape: document.getElementById('bg-shape').value, rotation_offset: 0.0 }; // Hiển thị progress showProgress(); updateProgress(10, 'Đang gửi request...'); // Disable submit button const submitBtn = document.getElementById('submit-btn'); submitBtn.disabled = true; submitBtn.textContent = 'Đang tạo...'; // Thu nhỏ cửa sổ chính, hiển thị thanh tiến trình toàn màn hình hideModal(); showGlobalProgress(); document.body.style.overflow = 'auto'; setStartButtonLoading(true); // Gọi API updateProgress(30, 'Đang tạo tranh...'); const response = await fetch(`${API_BASE_URL}/create-art`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Lỗi tạo tranh'); } updateProgress(70, 'Đang xử lý kết quả...'); const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Lỗi tạo tranh'); } currentSessionId = result.session_id; updateProgress(100, 'Hoàn thành!'); // Đợi một chút rồi hiển thị gallery setTimeout(() => { loadGallery(result.session_id); }, 500); } catch (error) { console.error('Lỗi:', error); alert(`Lỗi: ${error.message}`); updateProgress(0, ''); progressContainer.classList.add('hidden'); hideGlobalProgress(); showModal(); showInputForm(); setStartButtonLoading(false); // Enable submit button lại const submitBtn = document.getElementById('submit-btn'); submitBtn.disabled = false; submitBtn.textContent = 'Bắt đầu tạo'; } } async function loadGallery(sessionId) { try { // Gọi API lấy danh sách hình const response = await fetch(`${API_BASE_URL}/gallery/${sessionId}`); if (!response.ok) { throw new Error('Không thể lấy danh sách hình'); } const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Lỗi lấy danh sách hình'); } // Hiển thị gallery hideGlobalProgress(); showModal(); showGallery(); // Render hình ảnh renderGallery(result.images || []); } catch (error) { console.error('Lỗi:', error); alert(`Lỗi: ${error.message}`); hideGlobalProgress(); showModal(); showInputForm(); } } function renderGallery(images) { galleryImages = images || []; currentGalleryIndex = 0; setStartButtonLoading(false); // Debug: log images để kiểm tra console.log('renderGallery called with', images.length, 'images'); if (images.length > 0) { console.log('First image object:', images[0]); } if (galleryTitle) { galleryTitle.textContent = `🎨 ${galleryImages.length} Bức Tranh Đã Tạo`; } // Không còn nút select nữa renderGalleryThumbnails(); // Khởi tạo canvas sau khi gallery được hiển thị (đợi DOM update) // Sử dụng requestAnimationFrame để đảm bảo DOM đã render xong requestAnimationFrame(() => { setTimeout(() => { console.log('Initializing gallery canvas...'); console.log('galleryMainCanvas element:', galleryMainCanvas); console.log('galleryView element:', galleryView); console.log('galleryView.classList:', galleryView ? galleryView.classList.toString() : 'null'); if (!galleryMainCanvas) { console.error('galleryMainCanvas is null!'); return; } initGalleryCanvas(); if (galleryImages.length > 0) { console.log('Loading first image to canvas, total images:', galleryImages.length); setGalleryImage(0); } else { console.log('No images to display'); // Vẽ nền trắng nếu không có hình if (galleryMainCanvas && galleryCanvasCtx) { galleryCanvasCtx.fillStyle = '#ffffff'; galleryCanvasCtx.fillRect(0, 0, galleryMainCanvas.width, galleryMainCanvas.height); } } }, 100); // Tăng timeout lên 100ms để đảm bảo DOM đã render }); if (galleryImages.length === 0) { if (galleryMainCanvas && galleryCanvasCtx) { galleryCanvasCtx.clearRect(0, 0, galleryMainCanvas.width, galleryMainCanvas.height); } updateGalleryInfo(null); updateGalleryImageLabel(); if (galleryThumbnails) { galleryThumbnails.innerHTML = '

Chưa có hình nào

'; } if (galleryPrevBtn) galleryPrevBtn.disabled = true; if (galleryNextBtn) galleryNextBtn.disabled = true; } } function renderGalleryThumbnails() { if (!galleryThumbnails) return; galleryThumbnails.innerHTML = ''; galleryImages.forEach((image, index) => { const thumb = document.createElement('button'); thumb.type = 'button'; thumb.className = 'thumbnail-item'; thumb.dataset.index = index; // Luôn dùng clean image cho thumbnails (không có thông số) let imageSrc = ''; if (image.clean_image_url) { imageSrc = image.clean_image_url; } else if (image.image_url) { // Nếu không có clean_image_url, thử thay đổi URL để lấy clean image imageSrc = image.image_url.replace('?type=full', '?type=clean').replace(/\/api\/image\/([^\/]+)\/(\d+)$/, '/api/image/$1/$2?type=clean'); } else if (currentSessionId) { // Fallback: tạo URL clean image imageSrc = `${API_BASE_URL}/image/${currentSessionId}/${image.index}?type=clean`; } // Resolve URL nếu cần if (imageSrc && !imageSrc.startsWith('http')) { if (imageSrc.startsWith('/api')) { const baseUrl = API_BASE_URL.replace(/\/api\/?$/, ''); imageSrc = baseUrl + imageSrc; } else { imageSrc = API_BASE_URL + (imageSrc.startsWith('/') ? '' : '/') + imageSrc; } } thumb.innerHTML = `Hình #${image.index}`; thumb.addEventListener('click', () => setGalleryImage(index)); galleryThumbnails.appendChild(thumb); }); } function updateThumbnailActive() { if (!galleryThumbnails) return; Array.from(galleryThumbnails.children).forEach((child, idx) => { if (idx === currentGalleryIndex) { child.classList.add('active'); } else { child.classList.remove('active'); } }); } function resolveImageSrc(image, preferClean = true) { if (!image) { console.error('resolveImageSrc: image is null'); return ''; } let imageSrc = ''; // Luôn ưu tiên clean_image_url nếu preferClean = true if (preferClean) { if (image.clean_image_url) { imageSrc = image.clean_image_url; } else if (image.image_url) { // Nếu không có clean_image_url, thử thay đổi URL để lấy clean image imageSrc = image.image_url.replace('?type=full', '?type=clean').replace(/\/api\/image\/([^\/]+)\/(\d+)$/, '/api/image/$1/$2?type=clean'); } else if (currentSessionId && image.index) { // Fallback: tạo URL clean image imageSrc = `/api/image/${currentSessionId}/${image.index}?type=clean`; } } else { // Nếu không preferClean, dùng image_url if (image.image_url) { imageSrc = image.image_url; } else if (currentSessionId && image.index) { imageSrc = `/api/image/${currentSessionId}/${image.index}?type=full`; } } if (!imageSrc) { console.warn('No image URL found in image object:', image); return ''; } if (!imageSrc.startsWith('http')) { if (imageSrc.startsWith('/api')) { const baseUrl = API_BASE_URL.replace(/\/api\/?$/, ''); imageSrc = baseUrl + imageSrc; } else { imageSrc = API_BASE_URL + (imageSrc.startsWith('/') ? '' : '/') + imageSrc; } } console.log('Resolved image src:', imageSrc, '(preferClean:', preferClean, ')'); return imageSrc; } function navigateGallery(delta) { if (!galleryImages.length) return; const newIndex = currentGalleryIndex + delta; if (newIndex < 0 || newIndex >= galleryImages.length) return; setGalleryImage(newIndex); } function updateGalleryNavButtons() { if (galleryPrevBtn) { galleryPrevBtn.disabled = currentGalleryIndex <= 0; } if (galleryNextBtn) { galleryNextBtn.disabled = currentGalleryIndex >= galleryImages.length - 1; } } function updateGalleryInfo(metadata) { if (!galleryInfoText) { console.warn('galleryInfoText element not found'); return; } if (!metadata) { galleryInfoText.textContent = 'Đang tải thông tin...'; return; } const paperSize = metadata.paper_size || 'N/A'; const bgShape = metadata.bg_shape || 'vuông'; const bgColor = metadata.bg_color || 'N/A'; const penColor = metadata.pen_color || 'N/A'; let sizeText = ''; if (bgShape.toLowerCase() === 'tròn') { sizeText = `Đường kính D= ${paperSize} mm`; } else { sizeText = `${paperSize}x${paperSize} mm`; } galleryInfoText.textContent = `Kích thước tranh: ${sizeText} | Loại hình: ${bgShape} | Màu nền: ${bgColor} | Màu nét vẽ: ${penColor}`; console.log('Gallery info updated:', galleryInfoText.textContent); } function updateGalleryImageLabel() { if (galleryImageLabelText) { galleryImageLabelText.textContent = `Hình ${currentGalleryIndex + 1}/${galleryImages.length}`; } } async function setGalleryImage(index) { console.log('setGalleryImage called with index:', index); if (!galleryImages.length || index < 0 || index >= galleryImages.length) { console.error('Invalid index or no images:', index, 'total:', galleryImages.length); return; } currentGalleryIndex = index; const currentImage = galleryImages[index]; console.log('Current image object:', currentImage); // Dừng GIF nếu đang chạy if (isGifMode) { switchToImageMode(); } // Reset drag offset khi chuyển hình imageOffsetX = 0; imageOffsetY = 0; isDragging = false; // Load image vào canvas console.log('Calling loadImageToGalleryCanvas...'); await loadImageToGalleryCanvas(currentImage); console.log('loadImageToGalleryCanvas completed'); updateGalleryNavButtons(); updateThumbnailActive(); updateGalleryInfo(currentImage.metadata); updateGalleryImageLabel(); // Load metadata và path_data ngay lập tức if (currentImage.path_data) { // Nếu đã có path_data, extract metadata currentPathData = currentImage.path_data; if (!currentImage.metadata) { currentImage.metadata = { paper_size: currentImage.path_data.paper_size || '-', bg_shape: currentImage.path_data.bg_shape || '-', bg_color: currentImage.path_data.bg_color || '-', pen_color: currentImage.path_data.pen_color || '-' }; } updateGalleryInfo(currentImage.metadata); } else if (currentImage.path_data_url) { // Load path_data từ URL try { console.log('Loading path data from:', currentImage.path_data_url); // Resolve URL đúng cách let pathDataUrl = currentImage.path_data_url; if (!pathDataUrl.startsWith('http')) { if (pathDataUrl.startsWith('/api')) { const baseUrl = API_BASE_URL.replace(/\/api\/?$/, ''); pathDataUrl = baseUrl + pathDataUrl; } else { pathDataUrl = API_BASE_URL + (pathDataUrl.startsWith('/') ? '' : '/') + pathDataUrl; } } console.log('Resolved path data URL:', pathDataUrl); const response = await fetch(pathDataUrl); if (response.ok) { const data = await response.json(); console.log('Path data loaded:', data); currentImage.path_data = data; currentPathData = data; // Extract metadata từ path_data currentImage.metadata = { paper_size: data.paper_size || '-', bg_shape: data.bg_shape || '-', bg_color: data.bg_color || '-', pen_color: data.pen_color || '-' }; updateGalleryInfo(currentImage.metadata); } else { console.error('Failed to load path data, status:', response.status); updateGalleryInfo(null); } } catch (err) { console.error('Error loading path data:', err); updateGalleryInfo(null); } } else if (currentImage.metadata) { // Nếu đã có metadata, hiển thị ngay updateGalleryInfo(currentImage.metadata); } else { // Nếu không có gì, hiển thị thông báo updateGalleryInfo(null); } } function viewImage(imageIndex) { // Mở hình trong tab mới hoặc modal lớn hơn const imageEntry = galleryImages.find((img) => img.index === imageIndex); const imageUrl = resolveImageSrc(imageEntry || { image_url: `/api/image/${currentSessionId}/${imageIndex}?type=clean` }, true); window.open(imageUrl, '_blank'); } // Hiển thị dialog tải DXF (giống desktop app) function showDXFDownloadDialog(imageIndex) { return new Promise((resolve) => { // Lấy các element const dxfModal = document.getElementById('dxf-download-modal'); const emailInput = document.getElementById('dxf-email-input'); const codeInput = document.getElementById('dxf-code-input'); const confirmBtn = document.getElementById('dxf-confirm-btn'); const cancelBtn = document.getElementById('dxf-cancel-btn'); const closeBtn = document.getElementById('close-dxf-modal-btn'); if (!dxfModal || !emailInput || !codeInput || !confirmBtn || !cancelBtn) { console.warn('DXF dialog elements not found'); resolve(null); return; } // Reset form emailInput.value = ''; codeInput.value = ''; // Hiển thị modal dxfModal.classList.remove('hidden'); // Focus vào email input setTimeout(() => emailInput.focus(), 100); // Validation email function validateEmail(email) { const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; return pattern.test(email); } // Check email domain (async) async function checkEmailDomain(email) { try { const domain = email.split('@')[1]; // Sử dụng DNS lookup qua API hoặc đơn giản chỉ validate format // Trên web, việc check DNS domain phức tạp, nên chỉ validate format return true; } catch (e) { return false; } } // Xử lý xác nhận const handleConfirm = async () => { const email = emailInput.value.trim(); const code = codeInput.value.trim(); if (!email) { alert('Vui lòng nhập email!'); return; } if (!validateEmail(email)) { alert('Email không hợp lệ!'); return; } // Check domain (có thể bỏ qua trên web vì phức tạp) // const domainValid = await checkEmailDomain(email); // if (!domainValid) { // if (!confirm('Không thể xác minh domain email!\n\nBạn có chắc chắn muốn tiếp tục không?')) { // return; // } // } if (!code) { alert('Vui lòng nhập mã tải file!'); return; } // Đóng modal dxfModal.classList.add('hidden'); // Remove listeners confirmBtn.removeEventListener('click', handleConfirm); cancelBtn.removeEventListener('click', handleCancel); if (closeBtn) closeBtn.removeEventListener('click', handleCancel); if (dxfModal) dxfModal.removeEventListener('click', handleOutsideClick); // Resolve với email và code resolve({ email, code }); }; // Xử lý hủy const handleCancel = () => { dxfModal.classList.add('hidden'); // Remove listeners confirmBtn.removeEventListener('click', handleConfirm); cancelBtn.removeEventListener('click', handleCancel); if (closeBtn) closeBtn.removeEventListener('click', handleCancel); if (dxfModal) dxfModal.removeEventListener('click', handleOutsideClick); resolve(null); }; // Đóng khi click bên ngoài const handleOutsideClick = (e) => { if (e.target === dxfModal) { handleCancel(); } }; // Enter key để xác nhận const handleKeyPress = (e) => { if (e.key === 'Enter') { handleConfirm(); } else if (e.key === 'Escape') { handleCancel(); } }; // Thêm event listeners confirmBtn.addEventListener('click', handleConfirm); cancelBtn.addEventListener('click', handleCancel); if (closeBtn) closeBtn.addEventListener('click', handleCancel); if (dxfModal) dxfModal.addEventListener('click', handleOutsideClick); emailInput.addEventListener('keydown', handleKeyPress); codeInput.addEventListener('keydown', handleKeyPress); }); } async function downloadDXF(imageIndex) { try { // Hiển thị dialog nhập email và mã tải (giống desktop app) const result = await showDXFDownloadDialog(imageIndex); if (!result) { return; // Người dùng hủy } const { email, code } = result; // Gọi API download DXF const response = await fetch(`${API_BASE_URL}/download-dxf`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: currentSessionId, image_index: imageIndex, email: email, download_code: code }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Lỗi tải DXF'); } const apiResult = await response.json(); if (apiResult.success) { // Tải file DXF xuống thư mục Downloads của khách hàng const dxfUrl = `${API_BASE_URL}${apiResult.dxf_url}`; const dxfFilename = apiResult.dxf_filename || 'drawing.dxf'; try { // Dùng fetch để tải file dưới dạng blob const fileResponse = await fetch(dxfUrl); if (!fileResponse.ok) { throw new Error('Không thể tải file DXF'); } // Chuyển response thành blob const blob = await fileResponse.blob(); // Tạo URL từ blob const blobUrl = window.URL.createObjectURL(blob); // Tạo link tải tự động const link = document.createElement('a'); link.href = blobUrl; link.download = dxfFilename; // Tên file sẽ được lưu link.style.display = 'none'; // Ẩn link // Thêm vào DOM trước khi click document.body.appendChild(link); // Trigger click để tải file link.click(); // Dọn dẹp: xóa link và revoke blob URL setTimeout(() => { document.body.removeChild(link); window.URL.revokeObjectURL(blobUrl); }, 100); // Thông báo thành công (không làm thay đổi trang) alert(`Đã tải file DXF thành công!\n\nFile: ${dxfFilename}\nĐã được lưu vào thư mục Downloads.`); } catch (downloadError) { console.error('Lỗi tải file:', downloadError); // Fallback: thử cách cũ với link trực tiếp const link = document.createElement('a'); link.href = dxfUrl; link.download = dxfFilename; link.target = '_blank'; // Mở trong tab mới nếu cần link.style.display = 'none'; document.body.appendChild(link); link.click(); setTimeout(() => { document.body.removeChild(link); }, 100); alert(`Đã tải file DXF thành công!\n\nFile: ${dxfFilename}\nĐã được lưu vào thư mục Downloads.`); } } else { throw new Error(apiResult.error || 'Lỗi tải DXF'); } } catch (error) { console.error('Lỗi tải DXF:', error); alert(`Lỗi tải DXF: ${error.message}\n\nVui lòng thử lại.`); // KHÔNG đóng gallery, chỉ hiển thị thông báo lỗi // Gallery vẫn mở để người dùng có thể thử lại hoặc làm việc khác } } // Cửa sổ "Hình Đã Chọn" function openSelectedImageWindow(index) { if (index < 0 || index >= galleryImages.length) return; currentSelectedImageIndex = index; const image = galleryImages[index]; if (selectedImageTitle) { selectedImageTitle.textContent = `🎨 Hình #${image.index} - Đã Chọn`; } // Hiển thị thông tin cơ bản if (selectedBasicInfoText && image.metadata) { const metadata = image.metadata; const paperSize = metadata.paper_size || 'N/A'; const bgShape = metadata.bg_shape || 'vuông'; const bgColor = metadata.bg_color || 'N/A'; const penColor = metadata.pen_color || 'N/A'; let sizeText = ''; if (bgShape.toLowerCase() === 'tròn') { sizeText = `Đường kính D= ${paperSize} mm`; } else { sizeText = `${paperSize}x${paperSize} mm`; } selectedBasicInfoText.textContent = `Kích thước tranh: ${sizeText} | Loại hình: ${bgShape} | Màu nền: ${bgColor} | Màu nét vẽ: ${penColor}`; } // Load và vẽ hình lên canvas loadImageToCanvas(image); // Reset zoom currentZoomLevel = 0.3; updateZoomDisplay(); // Hiển thị cửa sổ if (selectedImageWindow) { selectedImageWindow.classList.remove('hidden'); } // Reset nút GIF/Hình if (selectedViewGifBtn) { selectedViewGifBtn.disabled = false; selectedViewGifBtn.style.background = '#6BA3D6'; selectedViewGifBtn.style.color = 'black'; } if (selectedViewImageBtn) { selectedViewImageBtn.disabled = true; selectedViewImageBtn.style.background = '#9BC4E2'; selectedViewImageBtn.style.color = 'darkgray'; } } function hideSelectedImageWindow() { if (selectedImageWindow) { selectedImageWindow.classList.add('hidden'); } currentSelectedImageIndex = null; currentImageObj = null; } async function loadImageToCanvas(image) { if (!selectedImageCanvas) return; const ctx = selectedImageCanvas.getContext('2d'); if (!ctx) return; canvasCtx = ctx; const imageUrl = resolveImageSrc(image, true); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { currentImageObj = img; drawCanvas(); }; img.onerror = () => { console.error('Lỗi tải hình'); }; img.src = imageUrl; } function drawCanvas() { if (!selectedImageCanvas || !canvasCtx || !currentImageObj) return; const canvas = selectedImageCanvas; const ctx = canvasCtx; const img = currentImageObj; // Tính kích thước canvas dựa trên zoom const actualZoom = currentZoomLevel * 2.0; const canvasWidth = canvas.parentElement.clientWidth - 20; const canvasHeight = 500; canvas.width = canvasWidth; canvas.height = canvasHeight; // Tính kích thước hình với zoom const imgWidth = img.width * actualZoom; const imgHeight = img.height * actualZoom; // Căn giữa hình const x = (canvasWidth - imgWidth) / 2; const y = (canvasHeight - imgHeight) / 2; // Xóa canvas ctx.clearRect(0, 0, canvasWidth, canvasHeight); // Vẽ hình ctx.drawImage(img, x, y, imgWidth, imgHeight); } function zoomCanvas(factor) { currentZoomLevel *= factor; if (currentZoomLevel < 0.2) currentZoomLevel = 0.2; if (currentZoomLevel > 1.0) currentZoomLevel = 1.0; updateZoomDisplay(); drawCanvas(); } function resetZoom() { currentZoomLevel = 0.3; updateZoomDisplay(); drawCanvas(); } function updateZoomDisplay() { if (zoomLevelText) { zoomLevelText.textContent = `${Math.round(currentZoomLevel * 100)}%`; } } // ============================================ // Gallery Canvas Functions (giống desktop app) // ============================================ // Khởi tạo canvas khi gallery được hiển thị function initGalleryCanvas() { console.log('initGalleryCanvas called'); if (!galleryMainCanvas) { console.error('galleryMainCanvas not found'); return; } console.log('galleryMainCanvas found, getting context...'); if (!galleryCanvasCtx) { galleryCanvasCtx = galleryMainCanvas.getContext('2d'); console.log('Canvas context obtained:', galleryCanvasCtx ? 'success' : 'failed'); } else { console.log('Canvas context already exists'); } // Set canvas size console.log('Updating canvas size...'); updateCanvasSize(); console.log('Canvas size updated, width:', galleryMainCanvas.width, 'height:', galleryMainCanvas.height); // Resize canvas khi window resize (chỉ thêm listener một lần) if (!window._galleryResizeListenerAdded) { window.addEventListener('resize', () => { updateCanvasSize(); if (galleryImages[currentGalleryIndex]) { loadImageToGalleryCanvas(galleryImages[currentGalleryIndex]); } }); window._galleryResizeListenerAdded = true; } // Thêm event listener cho mouse wheel zoom (chỉ thêm một lần) if (!galleryMainCanvas._wheelListenerAdded) { galleryMainCanvas.addEventListener('wheel', (event) => { event.preventDefault(); // Ngăn scroll trang onCanvasMouseWheel(event); }, { passive: false }); galleryMainCanvas._wheelListenerAdded = true; console.log('Mouse wheel zoom listener added to canvas'); } // Thêm event listener cho drag/pan (chỉ thêm một lần) if (!galleryMainCanvas._dragListenerAdded) { galleryMainCanvas.addEventListener('mousedown', onCanvasMouseDown); galleryMainCanvas.addEventListener('mousemove', onCanvasMouseMove); galleryMainCanvas.addEventListener('mouseup', onCanvasMouseUp); galleryMainCanvas.addEventListener('mouseleave', onCanvasMouseUp); // Dừng drag khi chuột ra khỏi canvas galleryMainCanvas._dragListenerAdded = true; console.log('Drag/pan listeners added to canvas'); } } // Update canvas size function updateCanvasSize() { if (!galleryMainCanvas) return; const wrapper = galleryMainCanvas.parentElement; if (wrapper) { const rect = wrapper.getBoundingClientRect(); // Đảm bảo có kích thước hợp lệ (dùng clientWidth/clientHeight thay vì getBoundingClientRect) const width = Math.max(wrapper.clientWidth || rect.width || 600, 400); const height = Math.max(wrapper.clientHeight || rect.height || 500, 400); galleryMainCanvas.width = width; galleryMainCanvas.height = height; // Set CSS size để canvas hiển thị đúng galleryMainCanvas.style.width = width + 'px'; galleryMainCanvas.style.height = height + 'px'; console.log(`Canvas size updated: ${width}x${height}`); } else { console.error('Canvas wrapper not found'); } } // Load image vào canvas async function loadImageToGalleryCanvas(image) { if (!galleryMainCanvas) { console.error('galleryMainCanvas not found in loadImageToGalleryCanvas'); return; } if (!galleryCanvasCtx) { initGalleryCanvas(); if (!galleryCanvasCtx) { console.error('Cannot get canvas context'); return; } } // Update canvas size trước khi vẽ updateCanvasSize(); // Luôn dùng clean image (không có thông số) cho canvas const imageSrc = resolveImageSrc(image, true); if (!imageSrc) { console.error('No image source found for image:', image); // Vẽ thông báo lỗi if (galleryCanvasCtx) { galleryCanvasCtx.fillStyle = '#ffffff'; galleryCanvasCtx.fillRect(0, 0, galleryMainCanvas.width, galleryMainCanvas.height); galleryCanvasCtx.fillStyle = '#ff0000'; galleryCanvasCtx.font = '16px Arial'; galleryCanvasCtx.fillText('Không tìm thấy hình ảnh', 10, 30); } return; } console.log('Loading clean image to canvas:', imageSrc); try { const img = new Image(); // Không set crossOrigin nếu là cùng origin if (imageSrc.startsWith('http') && !imageSrc.includes(window.location.hostname)) { img.crossOrigin = 'anonymous'; } await new Promise((resolve, reject) => { img.onload = () => { console.log('Image loaded successfully:', img.width, 'x', img.height); resolve(); }; img.onerror = (err) => { console.error('Image load error:', err, 'URL:', imageSrc); reject(err); }; img.src = imageSrc; }); // Vẽ image với zoom (giống desktop app: zoom_level * 2.0) const actualZoom = currentZoomLevel * 2.0; const canvasWidth = galleryMainCanvas.width; const canvasHeight = galleryMainCanvas.height; const imgWidth = img.width * actualZoom; const imgHeight = img.height * actualZoom; // Tính vị trí vẽ hình (căn giữa + offset từ drag) let x = (canvasWidth - imgWidth) / 2 + imageOffsetX; let y = (canvasHeight - imgHeight) / 2 + imageOffsetY; console.log(`Drawing image at zoom ${currentZoomLevel} (actual: ${actualZoom}), size: ${imgWidth}x${imgHeight}, pos: ${x}, ${y}, offset: ${imageOffsetX}, ${imageOffsetY}`); // Vẽ nền trắng trước (đảm bảo canvas luôn có background) galleryCanvasCtx.fillStyle = '#ffffff'; galleryCanvasCtx.fillRect(0, 0, canvasWidth, canvasHeight); // Vẽ hình (chỉ vẽ nếu có kích thước hợp lệ) if (imgWidth > 0 && imgHeight > 0 && canvasWidth > 0 && canvasHeight > 0) { console.log('Drawing image to canvas at position:', x, y); galleryCanvasCtx.drawImage(img, x, y, imgWidth, imgHeight); console.log('Image drawn successfully to canvas'); } else { console.warn('Image size invalid, skipping draw. imgWidth:', imgWidth, 'imgHeight:', imgHeight, 'canvasWidth:', canvasWidth, 'canvasHeight:', canvasHeight); } currentImageObj = img; // Force canvas to update (thử vẽ lại để đảm bảo) if (galleryMainCanvas) { console.log('Canvas final state - width:', galleryMainCanvas.width, 'height:', galleryMainCanvas.height, 'style.width:', galleryMainCanvas.style.width, 'style.height:', galleryMainCanvas.style.height); } } catch (err) { console.error('Không thể tải hình vào canvas:', err); // Vẽ thông báo lỗi if (galleryCanvasCtx) { galleryCanvasCtx.fillStyle = 'white'; galleryCanvasCtx.fillRect(0, 0, galleryMainCanvas.width, galleryMainCanvas.height); galleryCanvasCtx.fillStyle = 'red'; galleryCanvasCtx.font = '16px Arial'; galleryCanvasCtx.fillText('Lỗi tải hình', 10, 30); } } } // Zoom canvas function zoomGalleryCanvas(factor) { currentZoomLevel = Math.max(0.2, Math.min(1.0, currentZoomLevel * factor)); updateGalleryZoomLabel(); if (galleryImages[currentGalleryIndex]) { loadImageToGalleryCanvas(galleryImages[currentGalleryIndex]); } } // Reset zoom function resetGalleryZoom() { currentZoomLevel = 0.25; // 25% imageOffsetX = 0; // Reset offset khi reset zoom imageOffsetY = 0; updateGalleryZoomLabel(); if (galleryImages[currentGalleryIndex]) { loadImageToGalleryCanvas(galleryImages[currentGalleryIndex]); } } // Debounce cho zoom để tránh nháy let zoomTimeout = null; let pendingZoomLevel = null; let isZooming = false; // Flag để tránh zoom quá nhiều lần // Xử lý mouse down để bắt đầu drag function onCanvasMouseDown(event) { // Chỉ drag khi đang ở chế độ xem hình (không phải GIF) if (isGifMode) { return; } isDragging = true; dragStartX = event.clientX - imageOffsetX; dragStartY = event.clientY - imageOffsetY; galleryMainCanvas.style.cursor = 'grabbing'; } // Xử lý mouse move để drag hình function onCanvasMouseMove(event) { if (!isDragging || isGifMode) { return; } // Tính offset mới imageOffsetX = event.clientX - dragStartX; imageOffsetY = event.clientY - dragStartY; // Reload hình với offset mới if (galleryImages[currentGalleryIndex]) { loadImageToGalleryCanvas(galleryImages[currentGalleryIndex]); } } // Xử lý mouse up để kết thúc drag function onCanvasMouseUp(event) { if (isDragging) { isDragging = false; galleryMainCanvas.style.cursor = 'grab'; } } // Xử lý mouse wheel zoom (giống desktop app) function onCanvasMouseWheel(event) { // Chỉ zoom khi đang ở chế độ xem hình (không phải GIF) if (isGifMode) { return; } // Xác định hướng zoom: deltaY < 0 = zoom in, deltaY > 0 = zoom out const zoomFactor = event.deltaY < 0 ? 1.1 : 1.0 / 1.1; // Zoom tại trung tâm canvas (giống desktop app) const oldZoom = currentZoomLevel; let newZoom = oldZoom * zoomFactor; // Giới hạn zoom (giống desktop app: 0.2 - 1.0) const minZoom = 0.2; const maxZoom = 1.0; // Tính max zoom an toàn dựa trên kích thước canvas và hình if (newZoom > 1.0 && currentImageObj) { const canvasWidth = galleryMainCanvas.width; const canvasHeight = galleryMainCanvas.height; if (canvasWidth > 1 && canvasHeight > 1) { const imgWidth = currentImageObj.width; const imgHeight = currentImageObj.height; if (imgWidth > 0 && imgHeight > 0) { const maxZoomX = (canvasWidth * 0.95) / imgWidth; const maxZoomY = (canvasHeight * 0.95) / imgHeight; const maxZoomSafeActual = Math.min(maxZoomX, maxZoomY, maxZoom * 2.0); const maxZoomSafeActualClamped = Math.max(maxZoomSafeActual, 2.0); const maxZoomSafe = maxZoomSafeActualClamped / 2.0; newZoom = Math.min(newZoom, maxZoomSafe); } } } if (newZoom < minZoom) { newZoom = minZoom; } else if (newZoom > maxZoom) { newZoom = maxZoom; } // Chỉ update nếu thay đổi đáng kể (tránh zoom quá nhiều lần) if (Math.abs(newZoom - oldZoom) < 0.01) { return; } // Cập nhật zoom level ngay lập tức (để label cập nhật) currentZoomLevel = newZoom; updateGalleryZoomLabel(); // Debounce việc reload hình để tránh nháy - tăng delay và dùng requestAnimationFrame pendingZoomLevel = newZoom; if (zoomTimeout) { cancelAnimationFrame(zoomTimeout); } // Dùng requestAnimationFrame để mượt hơn zoomTimeout = requestAnimationFrame(() => { setTimeout(() => { if (pendingZoomLevel !== null && galleryImages[currentGalleryIndex]) { currentZoomLevel = pendingZoomLevel; loadImageToGalleryCanvas(galleryImages[currentGalleryIndex]); pendingZoomLevel = null; } }, 150); // Tăng delay lên 150ms để mượt hơn }); } // Update zoom label function updateGalleryZoomLabel() { if (galleryZoomLevelText) { galleryZoomLevelText.textContent = `${Math.round(currentZoomLevel * 100)}%`; } } // Switch to GIF mode function switchToGifMode() { if (isGifMode) return; // Kiểm tra có path_data không const currentImage = galleryImages[currentGalleryIndex]; if (!currentImage) { console.error('No current image'); alert('Không có hình ảnh để hiển thị GIF'); return; } // Nếu chưa có path_data, thử load if (!currentPathData) { if (currentImage.path_data) { // Nếu đã có path_data trong image object currentPathData = currentImage.path_data; console.log('Using existing path_data from image object'); } else if (currentImage.path_data_url) { // Load từ URL console.log('Loading path data for GIF from URL...'); loadPathDataForGif(currentImage.path_data_url).then((success) => { if (success && currentPathData) { console.log('Path data loaded successfully, starting GIF...'); isGifMode = true; updateGifButtons(); if (currentZoomLevel < 0.25) { currentZoomLevel = 0.25; updateGalleryZoomLabel(); } startGifAnimation(); } else { console.error('Failed to load path data'); alert('Không thể tải dữ liệu để hiển thị GIF. Vui lòng thử lại.'); } }).catch((err) => { console.error('Error in loadPathDataForGif promise:', err); alert('Lỗi khi tải dữ liệu GIF: ' + err.message); }); return; } else { console.error('No path_data or path_data_url available'); console.log('Current image object:', currentImage); alert('Không có dữ liệu để hiển thị GIF. Vui lòng thử tạo hình lại.'); return; } } // Nếu đã có path_data, bắt đầu GIF ngay isGifMode = true; updateGifButtons(); // Đảm bảo zoom level >= 25% if (currentZoomLevel < 0.25) { currentZoomLevel = 0.25; updateGalleryZoomLabel(); } startGifAnimation(); } function updateGifButtons() { if (galleryViewGifBtn) { galleryViewGifBtn.disabled = isGifMode; galleryViewGifBtn.style.background = isGifMode ? '#9BC4E2' : '#6BA3D6'; galleryViewGifBtn.style.color = isGifMode ? 'darkgray' : 'black'; } if (galleryViewImageBtn) { galleryViewImageBtn.disabled = !isGifMode; galleryViewImageBtn.style.background = !isGifMode ? '#9BC4E2' : '#6BA3D6'; galleryViewImageBtn.style.color = !isGifMode ? 'darkgray' : 'black'; } } // Switch to Image mode function switchToImageMode() { if (!isGifMode) return; isGifMode = false; if (animationFrameId) { clearTimeout(animationFrameId); animationFrameId = null; } // Reset drag state isDragging = false; updateGifButtons(); // Reload image if (galleryImages[currentGalleryIndex]) { loadImageToGalleryCanvas(galleryImages[currentGalleryIndex]); } } // Load path data cho GIF animation async function loadPathDataForGif(pathDataUrl) { try { console.log('Loading path data for GIF from:', pathDataUrl); // Resolve URL đúng cách let url = pathDataUrl; if (!url.startsWith('http')) { if (url.startsWith('/api')) { const baseUrl = API_BASE_URL.replace(/\/api\/?$/, ''); url = baseUrl + url; } else { url = API_BASE_URL + (url.startsWith('/') ? '' : '/') + url; } } console.log('Resolved URL for GIF:', url); const response = await fetch(url); if (response.ok) { const data = await response.json(); console.log('Path data loaded for GIF:', data); currentPathData = data; if (galleryImages[currentGalleryIndex]) { galleryImages[currentGalleryIndex].path_data = data; // Cũng extract metadata if (!galleryImages[currentGalleryIndex].metadata) { galleryImages[currentGalleryIndex].metadata = { paper_size: data.paper_size || '-', bg_shape: data.bg_shape || '-', bg_color: data.bg_color || '-', pen_color: data.pen_color || '-' }; } } return true; } else { console.error('Failed to load path data, status:', response.status); } } catch (err) { console.error('Error loading path data for GIF:', err); } return false; } // Start GIF animation (giống desktop app) function startGifAnimation() { if (!currentPathData || !galleryMainCanvas || !galleryCanvasCtx) { console.error('Cannot start GIF animation: missing data or canvas'); return; } if (animationFrameId) { clearTimeout(animationFrameId); animationFrameId = null; } try { const path_x = currentPathData.path_x || []; const path_y = currentPathData.path_y || []; const paper_size = currentPathData.paper_size || 400; const bg_color = currentPathData.bg_color || 'trắng'; const pen_color = currentPathData.pen_color || 'đen'; const bg_shape = currentPathData.bg_shape || 'vuông'; const rotation_offset = parseFloat(currentPathData.rotation_offset || 0); if (path_x.length === 0 || path_y.length === 0) { console.error('Invalid path data'); return; } const colorMapBg = { "trắng": "#ffffff", "đen": "#000000", "xám": "#808080" }; const colorMapPen = { "đen": "#000000", "đỏ": "#ff0000", "vàng": "#ffff00" }; const bgColorHex = colorMapBg[bg_color] || "#ffffff"; const penColorHex = colorMapPen[pen_color] || "#000000"; const canvasWidth = galleryMainCanvas.width; const canvasHeight = galleryMainCanvas.height; const actualZoom = currentZoomLevel * 2.0; const half = paper_size / 2; const margin = 5; const totalSizeMm = paper_size + 2 * margin; let scale; if (currentImageObj) { const imgWidth = currentImageObj.width; const zoomedWidth = imgWidth * actualZoom; scale = zoomedWidth / totalSizeMm; } else { scale = actualZoom; } const center_x = canvasWidth / 2; const center_y = canvasHeight / 2; function drawBackground() { galleryCanvasCtx.fillStyle = '#ffffff'; galleryCanvasCtx.fillRect(0, 0, canvasWidth, canvasHeight); const shadowLayers = 4; const shadowMaxSize = 6.0; const shadowBaseAlpha = 0.15; for (let layer = shadowLayers; layer > 0; layer--) { const layerSize = shadowMaxSize * (layer / shadowLayers); const grayValue = Math.floor(255 * (1 - shadowBaseAlpha * (layer / shadowLayers))); const shadowColor = `rgb(${grayValue}, ${grayValue}, ${grayValue})`; const shadowRadius = (half + layerSize) * scale; galleryCanvasCtx.fillStyle = shadowColor; if (bg_shape.toLowerCase() === "tròn") { galleryCanvasCtx.beginPath(); galleryCanvasCtx.arc(center_x, center_y, shadowRadius, 0, Math.PI * 2); galleryCanvasCtx.fill(); } else { galleryCanvasCtx.fillRect(center_x - shadowRadius, center_y - shadowRadius, shadowRadius * 2, shadowRadius * 2); } } galleryCanvasCtx.fillStyle = bgColorHex; if (bg_shape.toLowerCase() === "tròn") { galleryCanvasCtx.beginPath(); galleryCanvasCtx.arc(center_x, center_y, half * scale, 0, Math.PI * 2); galleryCanvasCtx.fill(); } else { galleryCanvasCtx.fillRect(center_x - half * scale, center_y - half * scale, half * scale * 2, half * scale * 2); } } drawBackground(); let currentPointIndex = 0; const totalPoints = path_x.length; const pointsPerSecond = 400; const delayMs = 1000 / pointsPerSecond; function drawFrame() { if (!isGifMode || currentPointIndex >= totalPoints) { animationFrameId = null; return; } galleryCanvasCtx.fillStyle = '#ffffff'; galleryCanvasCtx.fillRect(0, 0, canvasWidth, canvasHeight); drawBackground(); if (currentPointIndex > 1) { galleryCanvasCtx.strokeStyle = penColorHex; galleryCanvasCtx.lineWidth = 1; galleryCanvasCtx.beginPath(); for (let i = 0; i <= currentPointIndex; i++) { let x = path_x[i]; let y = path_y[i]; if (rotation_offset !== 0) { const rotationRad = -rotation_offset * Math.PI / 180; const cosR = Math.cos(rotationRad); const sinR = Math.sin(rotationRad); x = x * cosR + y * sinR; y = -path_x[i] * sinR + y * cosR; } const canvasX = center_x + x * scale; const canvasY = center_y - y * scale; if (i === 0) { galleryCanvasCtx.moveTo(canvasX, canvasY); } else { galleryCanvasCtx.lineTo(canvasX, canvasY); } } galleryCanvasCtx.stroke(); } if (currentPointIndex < totalPoints) { let penX = path_x[currentPointIndex]; let penY = path_y[currentPointIndex]; if (rotation_offset !== 0) { const rotationRad = -rotation_offset * Math.PI / 180; const cosR = Math.cos(rotationRad); const sinR = Math.sin(rotationRad); penX = penX * cosR + penY * sinR; penY = -path_x[currentPointIndex] * sinR + penY * cosR; } const penCanvasX = center_x + penX * scale; const penCanvasY = center_y - penY * scale; galleryCanvasCtx.fillStyle = '#ff0000'; galleryCanvasCtx.beginPath(); galleryCanvasCtx.arc(penCanvasX, penCanvasY, 3, 0, Math.PI * 2); galleryCanvasCtx.fill(); } currentPointIndex++; if (currentPointIndex < totalPoints && isGifMode) { // Dùng setTimeout trực tiếp thay vì requestAnimationFrame + setTimeout animationFrameId = setTimeout(drawFrame, delayMs); } else { animationFrameId = null; } } drawFrame(); } catch (err) { console.error('Error starting GIF animation:', err); switchToImageMode(); } } // Export functions để dùng trong HTML window.viewImage = viewImage; window.downloadDXF = downloadDXF; // ============================================ // Hướng dẫn và Thư viện // ============================================ // Khởi tạo Guide và Library khi DOM sẵn sàng function initGuideAndLibrary() { // DOM Elements cho Guide và Library const guideBtn = document.getElementById('btn-guide'); const libraryBtn = document.getElementById('btn-library'); const guideModal = document.getElementById('guide-modal'); const libraryModal = document.getElementById('library-modal'); const closeGuideBtn = document.getElementById('close-guide-btn'); const closeLibraryBtn = document.getElementById('close-library-btn'); const guideContent = document.getElementById('guide-content'); const libraryImages = document.getElementById('library-images'); const libraryLoading = document.getElementById('library-loading'); const libraryEmpty = document.getElementById('library-empty'); // Mở modal hướng dẫn async function openGuideModal() { try { if (!guideModal || !guideContent) { console.error('Guide modal elements not found'); alert('Không tìm thấy modal hướng dẫn. Vui lòng refresh trang.'); return; } guideModal.classList.remove('hidden'); guideContent.textContent = 'Đang tải hướng dẫn...'; console.log('Fetching guide from:', `${API_BASE_URL}/guide`); const response = await fetch(`${API_BASE_URL}/guide`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); console.log('Guide API response:', result); if (result.success) { guideContent.textContent = result.content || 'Nội dung hướng dẫn trống.'; } else { guideContent.textContent = `Lỗi: ${result.error || 'Không thể tải hướng dẫn'}`; } } catch (err) { console.error('Error loading guide:', err); if (guideContent) { guideContent.textContent = `Lỗi khi tải hướng dẫn: ${err.message || err}. Vui lòng kiểm tra:\n1. Backend server đang chạy tại http://localhost:5000\n2. File huongdansudung.txt tồn tại trong folder lucky_painter_app`; } alert(`Lỗi tải hướng dẫn: ${err.message || err}\n\nVui lòng kiểm tra:\n- Backend server đang chạy\n- Console (F12) để xem chi tiết lỗi`); } } // Đóng modal hướng dẫn function closeGuideModal() { if (guideModal) { guideModal.classList.add('hidden'); } } // Mở modal thư viện async function openLibraryModal() { try { if (!libraryModal || !libraryImages || !libraryLoading || !libraryEmpty) { console.error('Library modal elements not found'); alert('Không tìm thấy modal thư viện. Vui lòng refresh trang.'); return; } libraryModal.classList.remove('hidden'); libraryImages.innerHTML = ''; libraryLoading.classList.remove('hidden'); libraryEmpty.classList.add('hidden'); console.log('Fetching library from:', `${API_BASE_URL}/library`); const response = await fetch(`${API_BASE_URL}/library`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); console.log('Library API response:', result); libraryLoading.classList.add('hidden'); if (result.success && result.images && result.images.length > 0) { console.log(`Loading ${result.images.length} images`); result.images.forEach((filename, index) => { const imageItem = document.createElement('div'); imageItem.className = 'library-image-item'; const img = document.createElement('img'); const encodedFilename = encodeURIComponent(filename); const imageUrl = `${API_BASE_URL}/library/image/${encodedFilename}`; img.src = imageUrl; img.alt = filename; img.loading = 'lazy'; img.onerror = function() { console.error('Error loading image:', imageUrl); this.style.display = 'none'; imageItem.innerHTML = `
Lỗi tải hình: ${filename}
`; }; img.onload = function() { console.log('Image loaded successfully:', filename); }; const imageName = document.createElement('div'); imageName.className = 'image-name'; imageName.textContent = filename.length > 25 ? filename.substring(0, 22) + '...' : filename; imageItem.appendChild(img); imageItem.appendChild(imageName); // Click để xem hình lớn imageItem.addEventListener('click', () => { window.open(img.src, '_blank'); }); libraryImages.appendChild(imageItem); }); } else { libraryEmpty.textContent = result.success ? 'Thư viện chưa có hình ảnh nào.' : `Lỗi: ${result.error || 'Không thể tải danh sách hình ảnh'}`; libraryEmpty.classList.remove('hidden'); } } catch (err) { console.error('Error loading library:', err); if (libraryLoading) libraryLoading.classList.add('hidden'); if (libraryEmpty) { libraryEmpty.textContent = `Lỗi khi tải thư viện: ${err.message || err}\n\nVui lòng kiểm tra:\n1. Backend server đang chạy tại http://localhost:5000\n2. Folder thuvien_anh tồn tại trong lucky_painter_app`; libraryEmpty.classList.remove('hidden'); } alert(`Lỗi tải thư viện: ${err.message || err}\n\nVui lòng kiểm tra:\n- Backend server đang chạy\n- Console (F12) để xem chi tiết lỗi`); } } // Đóng modal thư viện function closeLibraryModal() { if (libraryModal) { libraryModal.classList.add('hidden'); } } // Event listeners if (guideBtn) { // Đảm bảo nút không bị disabled guideBtn.removeAttribute('disabled'); guideBtn.disabled = false; guideBtn.style.pointerEvents = 'auto'; guideBtn.style.cursor = 'pointer'; // Xóa listener cũ nếu có để tránh duplicate const newGuideBtn = guideBtn.cloneNode(true); guideBtn.parentNode.replaceChild(newGuideBtn, guideBtn); newGuideBtn.addEventListener('click', openGuideModal); newGuideBtn.setAttribute('data-listener-added', 'true'); console.log('Guide button event listener added', newGuideBtn); } else { console.warn('Guide button not found. Available buttons:', document.querySelectorAll('button[id*="guide"], button[id*="library"]')); } if (libraryBtn) { // Đảm bảo nút không bị disabled libraryBtn.removeAttribute('disabled'); libraryBtn.disabled = false; libraryBtn.style.pointerEvents = 'auto'; libraryBtn.style.cursor = 'pointer'; // Xóa listener cũ nếu có để tránh duplicate const newLibraryBtn = libraryBtn.cloneNode(true); libraryBtn.parentNode.replaceChild(newLibraryBtn, libraryBtn); newLibraryBtn.addEventListener('click', openLibraryModal); newLibraryBtn.setAttribute('data-listener-added', 'true'); console.log('Library button event listener added', newLibraryBtn); } else { console.warn('Library button not found. Available buttons:', document.querySelectorAll('button[id*="guide"], button[id*="library"]')); } if (closeGuideBtn) { closeGuideBtn.addEventListener('click', closeGuideModal); } if (closeLibraryBtn) { closeLibraryBtn.addEventListener('click', closeLibraryModal); } // Đóng modal khi click bên ngoài if (guideModal) { guideModal.addEventListener('click', (e) => { if (e.target === guideModal) { closeGuideModal(); } }); } if (libraryModal) { libraryModal.addEventListener('click', (e) => { if (e.target === libraryModal) { closeLibraryModal(); } }); } } // Khởi tạo khi DOM sẵn sàng function initGuideAndLibraryWhenReady() { // Đợi một chút để đảm bảo tất cả HTML đã được parse setTimeout(() => { initGuideAndLibrary(); }, 100); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initGuideAndLibraryWhenReady); } else { // DOM đã sẵn sàng, chạy ngay nhưng vẫn delay một chút initGuideAndLibraryWhenReady(); } // Cũng thử khởi tạo khi window load (backup) window.addEventListener('load', () => { setTimeout(() => { // Kiểm tra lại nếu chưa khởi tạo thành công const guideBtn = document.getElementById('btn-guide'); const libraryBtn = document.getElementById('btn-library'); if (guideBtn && !guideBtn.hasAttribute('data-listener-added')) { console.log('Re-initializing guide and library on window load'); initGuideAndLibrary(); } }, 200); });