jQuery(function ($) { // --- Google & eBay search buttons --- if (typeof CLIPON_VARS !== 'undefined' && (CLIPON_VARS.searchBrand || CLIPON_VARS.searchModel)) { var searchTerm = (CLIPON_VARS.searchBrand + ' ' + CLIPON_VARS.searchModel).trim(); var googleUrl = 'https://www.google.com/search?q=' + encodeURIComponent(searchTerm); var ebayUrl = 'https://www.ebay.com/sch/i.html?_nkw=' + encodeURIComponent(searchTerm); var $googleBtn = $('', { href: googleUrl, target: '_blank', class: 'clipon-search-btn clipon-search-btn-google', html: '' + searchTerm }); var $ebayBtn = $('', { href: ebayUrl, target: '_blank', class: 'clipon-search-btn clipon-search-btn-ebay', html: 'ebay ' + searchTerm + '' }); var $wrap = $(''); $wrap.append($googleBtn).append($ebayBtn); $('.wrap h1.wp-heading-inline').after($wrap); } const MIRROR_SWITCH_ID = 'clipon_mirror_toggle'; const BASE_COLOR = '#cccccc'; function isColorSwatchSelected() { return $('select.postform').val() === 'color'; } function insertMirrorSwitch() { if ($(`#${MIRROR_SWITCH_ID}`).length) return; const html = `
`; $('select.postform').closest('td, .form-field').append(html); $(`#${MIRROR_SWITCH_ID}`).on('change', function () { if (this.checked) { addMirrorPreview(); } else { $('#mirror-preview-canvas, #mirrored_preview, .mirror-preview-image').remove(); } }); } function getMirrorColor() { const $input = $('.woo-color'); return $input.length && $input.val().startsWith('#') ? $input.val() : '#666666'; } function addMirrorPreview() { const $originalPicker = $('.colorSelector').last().closest('.form-field'); if ($('#mirror-preview-canvas').length) return; const html = `
`; $originalPicker.after(html); bindColorChange(); updateMirrorPreview(); } function bindColorChange() { // Listen to standard change events $('.woo-color') .off('.clipon') .on('change.clipon input.clipon blur.clipon keyup.clipon', updateMirrorPreview); // Also monitor DOM changes in case the plugin updates the value silently const input = document.querySelector('.woo-color'); if (input) { const observer = new MutationObserver(updateMirrorPreview); observer.observe(input, { attributes: true, attributeFilter: ['value'] }); } } function updateMirrorPreview() { const mirrorColor = getMirrorColor(); const canvas = document.getElementById('mirror-preview-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const grad = ctx.createRadialGradient(27, 27, 10, 45, 45, 90); grad.addColorStop(0, '#cccccc'); grad.addColorStop(1, mirrorColor); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height); const dataURL = canvas.toDataURL('image/png'); $('#mirrored_preview').val(dataURL); $('.mirror-preview-image').html(``); } function initMirrorSupport() { $('select.postform').on('change', function () { if (isColorSwatchSelected()) { insertMirrorSwitch(); } else { $('#mirror-options, #mirror-preview-canvas, #mirrored_preview, .mirror-preview-image').remove(); } }); if (isColorSwatchSelected()) { insertMirrorSwitch(); } } initMirrorSupport(); }); jQuery(function ($) { // Only run this on the lens-color attribute list page if (window.location.href.includes('taxonomy=pa_lens-color')) { $('tr[data-mirrored="1"]').each(function () { const $row = $(this); const termId = $row.attr('id').replace('tag-', ''); const previewData = $row.find('td .mirror-preview-image img').attr('src') || $('input[name="mirrored_preview"]').val(); if (previewData) { const $thumbnailCell = $row.find('td.column-thumbnail'); $thumbnailCell.html(``); } }); } }); jQuery(function ($) { const previewValue = $('#mirrored_preview').val(); // Only run on taxonomy list screen if (window.location.href.includes('taxonomy=pa_lens-color') && previewValue) { // Find the last table row (newly added term) and inject thumbnail const $lastRow = $('table.wp-list-table tbody tr').first(); // or .last() if ordered differently const $thumbCell = $lastRow.find('td.column-thumbnail'); if ($thumbCell.length) { $thumbCell.html(``); } } }); // Customizer jQuery(function ($) { // ---------- Helpers ---------- const $root = $('#cliponRoot'); if (!$root.length) return; // Not a product page — exit early const $box = $root.find('.clipon-box'); const $wrap = $box.find('.clipon-image-wrapper'); const $img = $('#clipon-single-image'); const $uploader = $box.find('.clipon-upload-single .clipon-upload-btn'); const $guides = $wrap.find('.clipon-guides'); const $v1 = $guides.find('.v1'), $v2=$guides.find('.v2'), $v3=$guides.find('.v3'); const $h1 = $guides.find('.h1'), $h2=$guides.find('.h2'), $h3=$guides.find('.h3'), $h4=$guides.find('.h4'); const $editBtn = $wrap.find('.clipon-edit'); const $traceBtn = $wrap.find('.clipon-trace'); const $resetTabBtn = $root.find('#clipon-reset-right'); const $resetBtn = $wrap.find('.clipon-reset'); const $tabs = $root.find('.clipon-tab'); const $tabCal = $tabs.filter('[data-panel="cal"]'); const $tabTrace = $tabs.filter('[data-panel="trace"]'); const $panels = { cal: $('#panel-cal'), trace: $('#panel-trace') }; const $gear = $panels.cal.find('.clipon-gear'); const $settings = $panels.cal.find('.clipon-settings'); const $calibrationSwitch = $panels.cal.find('.calibration-mode-switch'); const $incStep = $settings.find('.step'); const $incZoom = $settings.find('.zoomInc'); const $incSX = $settings.find('.sxInc'); const $incSY = $settings.find('.syInc'); const $incRot = $settings.find('.rotInc'); const $frame = { A: $panels.cal.find('.clipon-eye-a'), B: $panels.cal.find('.clipon-eye-b'), Br: $panels.cal.find('.clipon-bridge'), H: $panels.cal.find('.clipon-height'), Mat:$panels.cal.find('.clipon-material'), DPI:$panels.cal.find('.clipon-dpi'), Col:$panels.cal.find('.clipon-color'), }; const $tracePanel = $panels.trace; const $traceMsg = $tracePanel.find('.clipon-trace-msg'); const $modeSwitch = $tracePanel.find('.clipon-mode-switch'); const $drillToggle= $tracePanel.find('.clipon-drill-toggle'); const $drillPanel = $tracePanel.find('.clipon-drill-panel'); const $drLeftTop = $tracePanel.find('.clipon-drill-left-top'); const $drLeftBottom = $tracePanel.find('.clipon-drill-left-bottom'); const $drBridge = $tracePanel.find('.clipon-drill-bridge'); const $drFactorSpring = $tracePanel.find('.clipon-drill-factor-spring'); const $drFactorMag = $tracePanel.find('.clipon-drill-factor-mag'); const $magOnly = $tracePanel.find('.mag-only'); const $springOnly = $tracePanel.find('.spring-only'); const $svg = $('#clipon-trace-svg'); const $path = $('#clipon-trace-path'); const $gDrills = $('#clipon-drills'); const $gAnch = $('#clipon-trace-handles'); const $gLines = $('#clipon-control-lines'); const $gCtrl = $('#clipon-control-handles'); const $mask = $wrap.find('.trace-mask'); const $download = $root.find('.clipon-download'); const $virtualBtn = $root.find('.clipon-virtual-btn'); // Virtual Clip-On popup handlers const $virtualPopup = $root.find('.clipon-virtual-popup'); const $virtualClose = $virtualPopup.find('.clipon-virtual-close'); const $virtualOverlay = $virtualPopup.find('.clipon-virtual-overlay'); $virtualBtn.on('click', function() { renderVirtualClipon(); $virtualPopup.show(); }); $virtualClose.on('click', function() { $virtualPopup.hide(); }); $virtualOverlay.on('click', function() { $virtualPopup.hide(); }); // Handle background toggle change $(document).on('change', '#clipon-show-background', function() { renderVirtualClipon(); }); $('#clipon-generate-variations').on('click', function() { generateVariationImages(); }); function generateVariationImages() { const $btn = $('#clipon-generate-variations'); const $progress = $('#clipon-generation-progress'); const $progressText = $('#clipon-progress-text'); const key = activeSize || '_single'; const ts = traceBySize.get(key); if (!ts) { alert('No trace data available'); return; } $btn.prop('disabled', true); $progress.show(); $progressText.text('Fetching variations...'); // Get product variations $.ajax({ url: CLIPON_VARS.ajaxUrl, type: 'POST', data: { action: 'clipon_get_variations', nonce: CLIPON_VARS.uploadNonce, post_id: CLIPON_VARS.postId }, success: function(response) { if (response.success && response.data) { // Filter by current bridge mode const currentMode = ts.magMode || 'spring'; const filtered = response.data.filter(v => v.bridge_type === currentMode); console.log('All variations:', response.data); console.log('Current mode:', currentMode); console.log('Filtered variations:', filtered); if (filtered.length === 0) { alert('No variations found for ' + currentMode + ' bridge'); $btn.prop('disabled', false); $progress.hide(); return; } processVariations(filtered, $progressText, function() { $btn.prop('disabled', false); $progress.hide(); alert('Generated ' + filtered.length + ' ' + currentMode + ' variation images successfully!'); }); } else { alert('Failed to fetch variations'); $btn.prop('disabled', false); $progress.hide(); } }, error: function() { alert('Error fetching variations'); $btn.prop('disabled', false); $progress.hide(); } }); } function processVariations(variations, $progressText, callback) { console.log('=== PROCESSING VARIATIONS ==='); console.log('Total variations to process:', variations.length); variations.forEach((v, i) => { console.log(`${i}: ID=${v.id}, Bridge=${v.bridge_type}, Color=${v.lens_color_name}, File=${v.filename}`); }); let index = 0; function processNext() { if (index >= variations.length) { console.log('=== ALL VARIATIONS PROCESSED ==='); callback(); return; } const variation = variations[index]; console.log(`>>> Processing variation ${index + 1}/${variations.length}: ${variation.filename}`); $progressText.text(`Generating ${index + 1} of ${variations.length}...`); generateVariationImage(variation, function() { console.log(`<<< Completed variation ${index + 1}: ${variation.filename}`); index++; setTimeout(processNext, 100); }); } processNext(); } function generateVariationImage(variation, callback) { console.log('=== Generating variation ==='); console.log('Variation ID:', variation.id); console.log('Filename:', variation.filename); const canvas = document.getElementById('clipon-virtual-canvas'); if (!canvas) { callback(); return; } const ctx = canvas.getContext('2d'); const canvasWidth = 800; const canvasHeight = 400; // Clear canvas ctx.clearRect(0, 0, canvasWidth, canvasHeight); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); const img = $img[0]; const pathD = $path.attr('d'); const key = activeSize || '_single'; const ts = traceBySize.get(key); if (!img || !img.complete || !pathD || !ts) { callback(); return; } const wrapWidth = $wrap.width(); const wrapHeight = $wrap.height(); const scale = Math.min((canvasWidth / 2) / wrapWidth, canvasHeight / wrapHeight); const dpi = parseFloat($frame.DPI.val()) || 350; const pxPerMm = dpi / 25.4; const clipWidthPx = 2.5 * pxPerMm; const clipLengthPx = 5.3 * pxPerMm; const stickOutPx = 1 * pxPerMm; const clipCenterOffset = stickOutPx - (clipLengthPx / 2); const springWidthPx = 19 * pxPerMm; const magneticWidthPx = 13 * pxPerMm; const lensColorData = variation.lens_color; const isImagePattern = lensColorData && (lensColorData.startsWith('http') || lensColorData.startsWith('/')); const bridgeType = variation.bridge_type || 'spring'; console.log('Is image pattern?', isImagePattern); // Prevent multiple callback calls let callbackCalled = false; function safeCallback() { if (!callbackCalled) { callbackCalled = true; callback(); } } // Set timeout for entire image loading process (8 seconds) const loadTimeout = setTimeout(function() { console.error('TIMEOUT loading images for:', variation.filename); safeCallback(); }, 8000); // Load images const clipImg = new Image(); clipImg.crossOrigin = 'anonymous'; const bridgeImg = new Image(); bridgeImg.crossOrigin = 'anonymous'; let lensImg = null; if (isImagePattern) { lensImg = new Image(); lensImg.crossOrigin = 'anonymous'; } let imagesLoaded = { clip: false, bridge: false, lens: !isImagePattern }; function checkAllLoaded() { if (imagesLoaded.clip && imagesLoaded.bridge && imagesLoaded.lens) { clearTimeout(loadTimeout); console.log('All images loaded, drawing...'); const capturedLensImg = lensImg; drawBothSides(capturedLensImg); } } // Clip image handlers clipImg.onerror = function() { console.error('Clip image failed to load'); clearTimeout(loadTimeout); safeCallback(); }; clipImg.onload = function() { console.log('Clip image loaded'); imagesLoaded.clip = true; checkAllLoaded(); }; // Bridge image handlers bridgeImg.onerror = function() { console.error('Bridge image failed to load'); clearTimeout(loadTimeout); safeCallback(); }; bridgeImg.onload = function() { console.log('Bridge image loaded'); imagesLoaded.bridge = true; checkAllLoaded(); }; // Lens image handlers if (lensImg) { lensImg.onerror = function() { console.error('Lens image failed to load:', lensColorData); // Continue without lens image (will use fallback) imagesLoaded.lens = true; checkAllLoaded(); }; lensImg.onload = function() { console.log('Lens image loaded:', lensColorData, lensImg.naturalWidth, 'x', lensImg.naturalHeight); imagesLoaded.lens = true; checkAllLoaded(); }; } // Start loading images clipImg.src = 'https://cliponexpress.com/wp-content/uploads/2015/12/clip.png'; if (bridgeType === 'spring') { bridgeImg.src = 'https://cliponexpress.com/wp-content/uploads/2015/12/spring.png'; } else { // Read magnetic bridge type - target specifically the bridge field container const magneticBridgeSelect = $root.find('.clipon-bridge-field .clipon-bridge-select'); const magneticBridgeValue = $root.find('.clipon-bridge-field .clipon-position-value'); let magneticBridgeType = ''; if (magneticBridgeSelect.length > 0) { magneticBridgeType = magneticBridgeSelect.val() || ''; } else if (magneticBridgeValue.length > 0) { magneticBridgeType = magneticBridgeValue.text().trim(); } console.log('Magnetic bridge type from form:', magneticBridgeType); // Check if "Curved" appears anywhere in the string const isCurved = magneticBridgeType.toLowerCase().includes('curved'); bridgeImg.src = isCurved ? 'https://cliponexpress.com/wp-content/uploads/icons/curvedmagnetic.png' : 'https://cliponexpress.com/wp-content/uploads/2015/12/straightmagnet.png'; console.log('Is curved?', isCurved, '- Using bridge image:', bridgeImg.src); } if (lensImg) { lensImg.src = lensColorData; } function drawBothSides(passedLensImage) { console.log('=== drawBothSides START ==='); try { drawSideForVariation(false, lensColorData, bridgeType, bridgeImg, passedLensImage); drawSideForVariation(true, lensColorData, bridgeType, bridgeImg, passedLensImage); console.log('Both sides drawn, creating blob...'); // Export as JPEG canvas.toBlob(function(blob) { if (!blob) { console.error('Blob creation failed'); safeCallback(); return; } const reader = new FileReader(); reader.onloadend = function() { uploadVariationImage(variation, reader.result, safeCallback); }; reader.onerror = function() { console.error('FileReader error'); safeCallback(); }; reader.readAsDataURL(blob); }, 'image/jpeg', 0.90); } catch(error) { console.error('Error in drawBothSides:', error); safeCallback(); } } function drawSideForVariation(mirror, colorData, mode, bImg, lensImage) { ctx.save(); ctx.beginPath(); if (mirror) { ctx.rect(canvasWidth / 2, 0, canvasWidth / 2, canvasHeight); } else { const extraSpace = 50; ctx.rect(0, 0, (canvasWidth / 2) + extraSpace, canvasHeight); } ctx.clip(); ctx.closePath(); if (mirror) { ctx.translate(canvasWidth * 3 / 4, canvasHeight / 2); ctx.scale(-scale, scale); } else { ctx.translate(canvasWidth / 4, canvasHeight / 2); ctx.scale(scale, scale); } ctx.translate(-wrapWidth / 2, -wrapHeight / 2); // Draw image only if background toggle is checked const showBackground = $('#clipon-show-background').is(':checked'); if (showBackground) { ctx.drawImage(img, 0, 0, wrapWidth, wrapHeight); } // Fill path with lens color (image pattern or solid color) at 80% opacity const path2D = new Path2D(pathD); ctx.globalAlpha = 0.8; if (lensImage && lensImage.complete && lensImage.naturalWidth > 0) { // Use image as pattern with scaled size, centered on lens const pathEl = document.getElementById('clipon-trace-path'); if (pathEl) { const bbox = pathEl.getBBox(); const centerX = bbox.x + bbox.width / 2; const centerY = bbox.y + bbox.height / 2; const tempCanvas = document.createElement('canvas'); const tempCtx = tempCanvas.getContext('2d'); const scaleFactor = 3; tempCanvas.width = lensImage.naturalWidth * scaleFactor; tempCanvas.height = lensImage.naturalHeight * scaleFactor; tempCtx.drawImage(lensImage, 0, 0, tempCanvas.width, tempCanvas.height); const pattern = ctx.createPattern(tempCanvas, 'repeat'); if (pattern) { const patternOffsetX = centerX % tempCanvas.width - tempCanvas.width / 2; const patternOffsetY = centerY % tempCanvas.height - tempCanvas.height / 2; const matrix = new DOMMatrix(); matrix.translateSelf(patternOffsetX, patternOffsetY); pattern.setTransform(matrix); ctx.fillStyle = pattern; } else { ctx.fillStyle = 'rgba(0, 0, 0, 1)'; } } else { ctx.fillStyle = 'rgba(0, 0, 0, 1)'; } } else { const rgb = hexToRgb(colorData || '#000000'); ctx.fillStyle = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; } ctx.fill(path2D); ctx.globalAlpha = 1.0; // Draw clips and bridge if (ts.drillData) { const pathEl = document.getElementById('clipon-trace-path'); if (pathEl) { const bbox = pathEl.getBBox(); const centerX = bbox.x + bbox.width / 2; const centerY = bbox.y + bbox.height / 2; const leftTopX = ts.drillData.leftTopX || 20; const leftBottomX = ts.drillData.leftBottomX || 20; const targetTopXpx = centerX - (leftTopX * pxPerMm); const targetBotXpx = centerX - (leftBottomX * pxPerMm); const topPt = samplePathForTargetXY(pathEl, targetTopXpx, 'x', -1, centerX, centerY); const botPt = samplePathForTargetXY(pathEl, targetBotXpx, 'x', +1, centerX, centerY); // Draw clips [topPt, botPt].forEach(pt => { if (pt) { const n = inwardNormalUnitAt(pathEl, pt.s, centerX, centerY); ctx.save(); ctx.translate(pt.x - n.x * clipCenterOffset, pt.y - n.y * clipCenterOffset); ctx.rotate(Math.atan2(-n.y, -n.x) + Math.PI / 2); ctx.drawImage(clipImg, -clipWidthPx / 2, -clipLengthPx / 2, clipWidthPx, clipLengthPx); ctx.restore(); } }); // Draw bridge if (mode === 'spring') { const frameData = readFrameInputs($root); const material = frameData.material; const matFactor = materialFactorForSpring(material); const factorSpring = (ts.drillData && ts.drillData.factorSpring) ? ts.drillData.factorSpring : 44; const bridgeVal = frameData.bridge || parseFloat($frame.Br.val()) || 10; const deltaMm = (factorSpring - (bridgeVal - matFactor)) / 2; const rightEdgePx = bbox.x + bbox.width; const startXpx = rightEdgePx - (deltaMm * pxPerMm); const startPx = samplePathForTargetXY(pathEl, startXpx, 'x', -1, centerX, centerY); if (startPx) { const holeX = startPx.x - (0.2 * pxPerMm) - (0.5 * pxPerMm); const holeY = startPx.y + (0.2 * pxPerMm) + (2.5 * pxPerMm); const springHeightPx = bImg.height * (springWidthPx / bImg.width); const anchorOffsetY = springHeightPx - (1 * pxPerMm); const springX = wrapWidth - springWidthPx; ctx.save(); ctx.drawImage(bImg, springX, holeY - anchorOffsetY + springBridgeVisualOffset, springWidthPx, springHeightPx); ctx.restore(); } } else { // Magnetic mode - right end aligned with mirror line (right edge of wrap) const bridgeMm = ts.drillData.bridge || 10; const magneticHeightPx = bImg.height * (magneticWidthPx / bImg.width); // Position: right end at right edge of wrap (mirror line) const bridgeX = wrapWidth - (magneticWidthPx / 2); const bridgeY = centerY - (bridgeMm * pxPerMm); ctx.save(); ctx.drawImage(bImg, bridgeX - magneticWidthPx / 2, bridgeY - magneticHeightPx / 2, magneticWidthPx, magneticHeightPx); ctx.restore(); } } } ctx.restore(); } } function hexToRgb(hex) { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : {r: 0, g: 0, b: 0}; } function uploadVariationImage(variation, imageData, callback) { $.ajax({ url: CLIPON_VARS.ajaxUrl, type: 'POST', data: { action: 'clipon_upload_variation_image', nonce: CLIPON_VARS.uploadNonce, variation_id: variation.id, filename: variation.filename, image_data: imageData }, success: function(response) { console.log('Uploaded:', variation.filename); callback(); }, error: function() { console.error('Failed:', variation.filename); callback(); } }); } var springBridgeVisualOffset = 0; $('#clipon-bridge-up').on('click', function() { springBridgeVisualOffset -= 2; $('#clipon-bridge-offset-value').text(springBridgeVisualOffset); renderVirtualClipon(); }); $('#clipon-bridge-down').on('click', function() { springBridgeVisualOffset += 2; $('#clipon-bridge-offset-value').text(springBridgeVisualOffset); renderVirtualClipon(); }); function renderVirtualClipon() { const canvas = document.getElementById('clipon-virtual-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const canvasWidth = 800; const canvasHeight = 400; // Clear canvas ctx.clearRect(0, 0, canvasWidth, canvasHeight); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); // Get current image and path const img = $img[0]; const pathD = $path.attr('d'); const key = activeSize || '_single'; const ts = traceBySize.get(key); if (!img || !img.complete || !pathD || !ts) { ctx.fillStyle = '#666'; ctx.font = '16px Arial'; ctx.textAlign = 'center'; ctx.fillText('No image or path available', canvasWidth / 2, canvasHeight / 2); return; } // Calculate scaling to fit canvas (half width for mirrored display) const wrapWidth = $wrap.width(); const wrapHeight = $wrap.height(); const scale = Math.min((canvasWidth / 2) / wrapWidth, canvasHeight / wrapHeight); const dpi = parseFloat($frame.DPI.val()) || 350; const pxPerMm = dpi / 25.4; // Clip dimensions: 2.5mm width, 5.3mm length, 1mm sticks out, 4.3mm goes in const clipWidthPx = 2.5 * pxPerMm; const clipLengthPx = 5.3 * pxPerMm; const stickOutPx = 1 * pxPerMm; const clipCenterOffset = stickOutPx - (clipLengthPx / 2); // Bridge dimensions const springWidthPx = 19 * pxPerMm; const magneticWidthPx = 13 * pxPerMm; // Load images const clipImg = new Image(); clipImg.crossOrigin = 'anonymous'; clipImg.src = 'https://cliponexpress.com/wp-content/uploads/2015/12/clip.png'; const springImg = new Image(); springImg.crossOrigin = 'anonymous'; springImg.src = 'https://cliponexpress.com/wp-content/uploads/2015/12/spring.png'; // Determine which magnetic bridge to use based on attribute const frameData = readFrameInputs($root); // Add debugging: console.log('=== DEBUGGING MAGNETIC BRIDGE ==='); console.log('All select elements:', $('select[name*="magnetic"]').length); $('select[name*="magnetic"]').each(function() { console.log('Found select:', $(this).attr('name'), '=', $(this).val()); }); console.log('All inputs with magnetic:', $('input[name*="magnetic"]').length); $('input[name*="magnetic"]').each(function() { console.log('Found input:', $(this).attr('name'), '=', $(this).val()); }); console.log('Checking attribute_pa_magnetic-bridge:', $('select[name="attribute_pa_magnetic-bridge"]').val()); console.log('=== END DEBUG ==='); // Read magnetic bridge type - target specifically the bridge field container const magneticBridgeSelect = $root.find('.clipon-bridge-field .clipon-bridge-select'); const magneticBridgeValue = $root.find('.clipon-bridge-field .clipon-position-value'); let magneticBridgeType = ''; if (magneticBridgeSelect.length > 0) { magneticBridgeType = magneticBridgeSelect.val() || ''; } else if (magneticBridgeValue.length > 0) { magneticBridgeType = magneticBridgeValue.text().trim(); } console.log('Virtual clipon - Magnetic bridge type:', magneticBridgeType); // Check if "Curved" appears anywhere in the string const isCurvedMagnetic = magneticBridgeType.toLowerCase().includes('curved'); const magneticImg = new Image(); magneticImg.crossOrigin = 'anonymous'; magneticImg.src = isCurvedMagnetic ? 'https://cliponexpress.com/wp-content/uploads/icons/curvedmagnetic.png' : 'https://cliponexpress.com/wp-content/uploads/2015/12/straightmagnet.png'; console.log('Virtual clipon - Is curved?', isCurvedMagnetic, '- Using bridge URL:', magneticImg.src); let loadedCount = 0; const totalImages = 3; function onImageLoad() { loadedCount++; if (loadedCount === totalImages) { drawSide(false); // Left side drawSide(true); // Right side (mirrored) } } clipImg.onload = onImageLoad; springImg.onload = onImageLoad; magneticImg.onload = onImageLoad; function drawSide(mirror) { ctx.save(); // Clip to half of canvas to prevent overlap at center ctx.beginPath(); if (mirror) { ctx.rect(canvasWidth / 2, 0, canvasWidth / 2, canvasHeight); } else { ctx.rect(0, 0, canvasWidth / 2, canvasHeight); } ctx.clip(); ctx.closePath(); if (mirror) { ctx.translate(canvasWidth * 3 / 4, canvasHeight / 2); ctx.scale(-scale, scale); } else { ctx.translate(canvasWidth / 4, canvasHeight / 2); ctx.scale(scale, scale); } ctx.translate(-wrapWidth / 2, -wrapHeight / 2); // Draw image // Draw image only if background toggle is checked const showBackground = $('#clipon-show-background').is(':checked'); if (showBackground) { ctx.drawImage(img, 0, 0, wrapWidth, wrapHeight); } // Fill path with black 80% opacity const path2D = new Path2D(pathD); ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; ctx.fill(path2D); // Draw clips and bridge at drill mark positions if (ts.drillData) { const pathEl = document.getElementById('clipon-trace-path'); if (pathEl) { const bbox = pathEl.getBBox(); const centerX = bbox.x + bbox.width / 2; const centerY = bbox.y + bbox.height / 2; const frameData = readFrameInputs($root); const material = frameData.material; const matFactor = materialFactorForSpring(material); // Get left X positions for drill marks (independent top and bottom) const leftTopX = ts.drillData.leftTopX || 20; const leftBottomX = ts.drillData.leftBottomX || 20; const targetTopXpx = centerX - (leftTopX * pxPerMm); const targetBotXpx = centerX - (leftBottomX * pxPerMm); // Sample path at these X positions to get Y positions const topPt = samplePathForTargetXY(pathEl, targetTopXpx, 'x', -1, centerX, centerY); const botPt = samplePathForTargetXY(pathEl, targetBotXpx, 'x', +1, centerX, centerY); // Draw side clips if (topPt) { const n = inwardNormalUnitAt(pathEl, topPt.s, centerX, centerY); ctx.save(); ctx.translate(topPt.x - n.x * clipCenterOffset, topPt.y - n.y * clipCenterOffset); ctx.rotate(Math.atan2(-n.y, -n.x) + Math.PI / 2); ctx.drawImage(clipImg, -clipWidthPx / 2, -clipLengthPx / 2, clipWidthPx, clipLengthPx); ctx.restore(); } if (botPt) { const n = inwardNormalUnitAt(pathEl, botPt.s, centerX, centerY); ctx.save(); ctx.translate(botPt.x - n.x * clipCenterOffset, botPt.y - n.y * clipCenterOffset); ctx.rotate(Math.atan2(-n.y, -n.x) + Math.PI / 2); ctx.drawImage(clipImg, -clipWidthPx / 2, -clipLengthPx / 2, clipWidthPx, clipLengthPx); ctx.restore(); } // Draw bridge based on mode const mode = ts.magMode || 'spring'; if (mode === 'spring') { // Find spring drill hole position const factorSpring = (ts.drillData && ts.drillData.factorSpring) ? ts.drillData.factorSpring : 44; const bridgeVal = frameData.bridge || parseFloat($frame.Br.val()) || 10; const deltaMm = (factorSpring - (bridgeVal - matFactor)) / 2; const rightEdgePx = bbox.x + bbox.width; const startXpx = rightEdgePx - (deltaMm * pxPerMm); const startPx = samplePathForTargetXY(pathEl, startXpx, 'x', -1, centerX, centerY); if (startPx) { // Hole is 0.5mm left and 2.5mm down from slot start const holeX = startPx.x - (0.2 * pxPerMm) - (0.5 * pxPerMm); const holeY = startPx.y + (0.2 * pxPerMm) + (2.5 * pxPerMm); // Spring bridge: right end at right edge of wrap (center line between displays) const springHeightPx = springImg.height * (springWidthPx / springImg.width); const anchorOffsetY = springHeightPx - (1 * pxPerMm); // Position right end at right edge of wrap area const springX = wrapWidth - springWidthPx; ctx.save(); ctx.drawImage(springImg, springX, holeY - anchorOffsetY + springBridgeVisualOffset, springWidthPx, springHeightPx); ctx.restore(); } } else { // Magnetic mode - right end aligned with mirror line (right edge of wrap) const bridgeMm = ts.drillData.bridge || 10; const magneticHeightPx = magneticImg.height * (magneticWidthPx / magneticImg.width); // Position: right end at right edge of wrap (mirror line) const bridgeX = wrapWidth - (magneticWidthPx / 2); const bridgeY = centerY - (bridgeMm * pxPerMm); ctx.save(); // Draw centered at position, always horizontal (no rotation) ctx.drawImage(magneticImg, bridgeX - magneticWidthPx / 2, bridgeY - magneticHeightPx / 2, magneticWidthPx, magneticHeightPx); ctx.restore(); } } } ctx.restore(); } // If all images are already loaded, draw immediately if (clipImg.complete && springImg.complete && magneticImg.complete) { drawSide(false); drawSide(true); } } // === SVG helpers (namespace-safe) === const NS = 'http://www.w3.org/2000/svg'; function svgEl(tag, attrs={}) { const el = document.createElementNS(NS, tag); for (const [k,v] of Object.entries(attrs)) el.setAttribute(k, String(v)); return el; } function svgAppend($g, el) { ($g && $g[0] ? $g[0] : $g).appendChild(el); } function svgShow($el) { $el.each(function(){ this.style.display='inline'; }); } function svgHide($el) { $el.each(function(){ this.style.display='none'; }); } function svgClear($el) { $el.each(function(){ while (this.firstChild) this.removeChild(this.firstChild); }); } // Pointer behavior: off in Calibrate, on in Trace $svg.css({ zIndex: 30, pointerEvents: 'none' }); // Hidden inputs const $hidden = $('.clipon-hidden'); const $hid = { id: $hidden.find('input[name="_clipon_single_image_id"]'), url: $hidden.find('input[name="_clipon_single_image_url"]'), transform: $hidden.find('input[name="_clipon_transform"]'), frame: $hidden.find('input[name="_clipon_frame"]'), path: $hidden.find('input[name="_clipon_trace_path"]'), points: $hidden.find('input[name="_clipon_trace_points"]'), handles: $hidden.find('input[name="_clipon_trace_handles"]'), drill: $hidden.find('input[name="_clipon_drill"]'), sizesMap: $hidden.find('input[name="_clipon_sizes_map"]'), activeSz: $hidden.find('input[name="_clipon_active_size"]'), doubleImgs:$hidden.find('input[name="_clipon_double_images"]'), }; // Size tabs (may be absent) const $sizeTabs = $root.find('.clipon-size-tab'); let activeSize = $sizeTabs.filter('.is-active').data('size') || ''; // ---------- State ---------- const state = { tx:0, ty:0, scale:1, sx:1, sy:1, rot:0, step:10, zoomInc:0.1, sxInc:0.1, syInc:0.1, rotInc:1 }; let calibrationMode = 'fine'; // 'fine' or 'coarse' let imageUploaded = !!$img.attr('src'); let baseSizeOnUpload = activeSize || ''; let baseAOnUpload = 0; let baseCalibrated = false; // Per-size trace: { mode:'placing'|'edit', points:[{x,y}], handles:[{hin:{x,y}, hout:{x,y}}], drillOpen, magMode:'spring'|'magnetic', springBackup } const traceBySize = new Map(); const HOLE_COLOR = '#ef4444'; // matches your CSS var --hole const TR_INDEX = 3; // br, bl, tl, tr → tr is index 3 (initial only) // Helper: Find the actual TR point (rightmost point on top half) // This ensures the original TR stays TR even when points are added function findTRIndex(ts) { if (!ts || !ts.points || ts.points.length < 4) return 3; // Find center Y of all points var minY = Math.min(...ts.points.map(p => p.y)); var maxY = Math.max(...ts.points.map(p => p.y)); var centerY = (minY + maxY) / 2; // Find rightmost point in the upper half var trIndex = 0; var maxX = -Infinity; for (var i = 0; i < ts.points.length; i++) { if (ts.points[i].y < centerY && ts.points[i].x > maxX) { maxX = ts.points[i].x; trIndex = i; } } return trIndex; } // ---------- Prevent form submit ---------- $(document).on('click', '.clipon-ui button, .clipon-controls button, .clipon-tabsbar button, .strip button, .dpad button, .clipon-drill-toggle, .clipon-download-btn', function (e) { e.preventDefault(); e.stopPropagation(); }); // ---------- Helper Functions for Download ---------- function pxPerMmFromDpi(dpi) { return dpi / 25.4; } function pxToMmPoint(px, py, centerX, centerY, dpi) { const pxPerMm = pxPerMmFromDpi(dpi); return { X: (px - centerX) / pxPerMm, Y: -(py - centerY) / pxPerMm // negative Y for coordinate system flip }; } function fmt1(num) { return Number(num).toFixed(1); } function readFrameInputs($section) { return { material: $section.find('.clipon-material').val() || 'plastic', bridge: parseFloat($section.find('.clipon-bridge').val()) || 0, dpi: parseFloat($section.find('.clipon-dpi').val()) || 350, eyeA: parseFloat($section.find('.clipon-eye-a').val()) || 0, magneticBridge: $section.find('.magnetic-bridge').val() || '' }; } function materialFactorForSpring(material) { switch (material) { case 'metal': return 0.4; case 'semi-rimless': return 0; case 'plastic': default: return 0.8; } } // Simple fallback functions for brand/model/eye data function cliponGetBrandFromCategories() { // Get product categories var categories = []; $('#taxonomy-product_cat input:checked').each(function() { // Get only the direct text node, excluding WordPress action links like "Make primary" var labelText = $(this).closest('label').clone().children().remove().end().text().trim(); categories.push(labelText); }); // Return first category as brand, or check for specific brand taxonomy return categories[0] || 'Generic'; } function cliponGetModelFromAttributes(debug = false) { let model = ''; // 1) Prefer the explicit pa_model taxonomy select in this product let $sel = jQuery('select.attribute_values[data-taxonomy="pa_model"]'); if ($sel.length) { // Try option text first const optTxt = $sel.find('option:selected').map(function () { return jQuery(this).text(); }).get().join(' ').trim(); if (optTxt) { model = optTxt; if (debug) console.log('[clipon] model via option:selected =', model); return model; } // Fallback: Select2 chips rendered as a sibling of THIS select const chipsTxt = $sel.nextAll('.select2').first() .find('.select2-selection__choice') .map(function () { return jQuery(this).attr('title') || jQuery(this).text(); }) .get().join(' ').replace(/×/g, '').trim(); if (chipsTxt) { model = chipsTxt; if (debug) console.log('[clipon] model via select2 chips =', model); return model; } } // 2) Fallback: look for a custom "model" attribute row const $row = jQuery('#product_attributes .woocommerce_attribute').filter(function () { const tax = (jQuery(this).find('select.attribute_taxonomy').val() || '').toLowerCase(); const name = (jQuery(this).find('input.attribute_name').val() || '').toLowerCase(); return tax === 'pa_model' || name === 'model' || /(^|[_-])model$/.test(name); }).first(); if ($row.length) { // chips inside that row (custom setups) const rowChips = $row.find('.select2-selection__choice').map(function () { return jQuery(this).attr('title') || jQuery(this).text(); }).get().join(' ').replace(/×/g, '').trim(); if (rowChips) { model = rowChips; if (debug) console.log('[clipon] model via row chips =', model); return model; } // plain select inside that row const rowOpt = $row.find('select.attribute_values option:selected').map(function () { return jQuery(this).text(); }).get().join(' ').trim(); if (rowOpt) { model = rowOpt; if (debug) console.log('[clipon] model via row option =', model); return model; } // free-text (custom attribute) const free = ($row.find('textarea.attribute_value, input.attribute_value').val() || '').toString().trim(); if (free) { model = free; if (debug) console.log('[clipon] model via row free text =', model); return model; } } if (debug) console.log('[clipon] final model (empty) = ""'); return ''; } function cliponGetEyeAndBridge() { // Get from current frame inputs const frameData = readFrameInputs($root); return { eyeA: frameData.eyeA || '50', bridge: frameData.bridge || '15' }; } // ---------- Media (single) ---------- function openMediaSingle(cb) { const frame = wp.media({ title: 'Select Image', multiple: false, library: {type:'image'} }); frame.on('select', function () { const att = frame.state().get('selection').first().toJSON(); cb({id: att.id, url: att.url}); }); frame.open(); } $uploader.on('click', function(){ openMediaSingle(function(p){ $hid.id.val(p.id); $hid.url.val(p.url); $wrap.show(); $img.attr('src', p.url).show(); imageUploaded = true; baseSizeOnUpload = activeSize || ''; baseAOnUpload = parseAB(activeSize).a || parseFloat($frame.A.val()) || 0; baseCalibrated = false; resetTransform(); applyTransform(); updateGuides(); }); }); // ---------- Transform ---------- function applyTransform() { const t = `translate(-50%,-50%) translate(${state.tx}px,${state.ty}px) rotate(${state.rot}deg) scale(${(state.sx*state.scale).toFixed(4)}, ${(state.sy*state.scale).toFixed(4)})`; $img.css({ transform: t, transformOrigin: 'center center' }); persistTransform(); syncPathWithImage(); } function resetTransform(){ Object.assign(state, { tx:0, ty:0, scale:1, sx:1, sy:1, rot:0 }); } function isDefaultTransform() { return Math.abs(state.tx)<1e-3 && Math.abs(state.ty)<1e-3 && Math.abs(state.rot)<1e-3 && Math.abs(state.scale-1)<1e-3 && Math.abs(state.sx-1)<1e-3 && Math.abs(state.sy-1)<1e-3; } function transformPathCoordinates(ts, transform) { const centerX = $wrap.width() / 2; const centerY = $wrap.height() / 2; const radians = transform.rot * Math.PI / 180; const cos = Math.cos(radians); const sin = Math.sin(radians); const scaleX = transform.sx * transform.scale; const scaleY = transform.sy * transform.scale; function transformPoint(p) { // Start from point position let x = p.x - centerX; let y = p.y - centerY; // Apply translation first (before rotation) x += transform.tx; y += transform.ty; // Apply rotation const rotX = x * cos - y * sin; const rotY = x * sin + y * cos; // Apply scale const scaledX = rotX * scaleX; const scaledY = rotY * scaleY; // Translate back from origin return { x: scaledX + centerX, y: scaledY + centerY }; } // Transform all points ts.points = ts.points.map(transformPoint); // Transform all handles if (ts.handles) { ts.handles = ts.handles.map(h => ({ hin: transformPoint(h.hin), hout: transformPoint(h.hout) })); } } function getIncrements() { const divisor = (calibrationMode === 'coarse') ? 10 : 1; return { step: state.step / divisor, zoom: state.zoomInc / divisor, sx: state.sxInc / divisor, sy: state.syInc / divisor, rot: state.rotInc / divisor }; } function maybeEnableTrace(){ if (imageUploaded && !isDefaultTransform()) { $tabTrace.prop('disabled', false).removeAttr('disabled'); } } function applyCalibrationToPath() { const key = (activeSize||'_single'); const ts = traceBySize.get(key); if (!ts || !ts.points || ts.points.length === 0) return; // Only apply if there are actual transforms to apply if (isDefaultTransform()) return; const centerX = $wrap.width() / 2; const centerY = $wrap.height() / 2; const scaleX = state.sx * state.scale; const scaleY = state.sy * state.scale; const rotation = state.rot * Math.PI / 180; // Convert to radians // Transform each point ts.points = ts.points.map(p => { // Translate to origin let x = p.x - centerX; let y = p.y - centerY; // Apply rotation const rotatedX = x * Math.cos(rotation) - y * Math.sin(rotation); const rotatedY = x * Math.sin(rotation) + y * Math.cos(rotation); // Apply scale x = rotatedX * scaleX; y = rotatedY * scaleY; // Translate back and apply translation return { x: x + centerX + state.tx, y: y + centerY + state.ty }; }); // Transform handles the same way if (ts.handles) { ts.handles = ts.handles.map(h => { const transformPoint = (point) => { let x = point.x - centerX; let y = point.y - centerY; const rotatedX = x * Math.cos(rotation) - y * Math.sin(rotation); const rotatedY = x * Math.sin(rotation) + y * Math.cos(rotation); x = rotatedX * scaleX; y = rotatedY * scaleY; return { x: x + centerX + state.tx, y: y + centerY + state.ty }; }; return { hin: transformPoint(h.hin), hout: transformPoint(h.hout) }; }); } // Reset transform state since it's now baked into the path resetTransform(); } function syncPathWithImage() { if (!$tabCal.hasClass('is-active') || !($path.attr('d')||'').trim()) { return; } const svgTransform = `translate(${state.tx}px,${state.ty}px) rotate(${state.rot}deg) scale(${state.scale}, ${state.scale}) scale(${state.sx}, ${state.sy})`; $svg.css({ transform: svgTransform, transformOrigin: 'center center', display: 'block', pointerEvents: 'none' }); } // Gear / increments $gear.on('click', function(){ $settings.toggle(); }); $settings.on('input', '.clipon-inc', function(){ state.step = parseFloat($incStep.val()) || state.step; state.zoomInc = parseFloat($incZoom.val()) || state.zoomInc; state.sxInc = parseFloat($incSX.val()) || state.sxInc; state.syInc = parseFloat($incSY.val()) || state.syInc; state.rotInc = parseFloat($incRot.val()) || state.rotInc; }); // Frame/Clip-On tabs const $frameTabBtn = $panels.cal.find('.frametab'); const $cliponTabBtn = $panels.cal.find('.clipontab'); const $frameFieldsBlock = $panels.cal.find('.frame-fields-block'); const $cliponFieldsBlock = $panels.cal.find('.clipon-fields-block'); $frameTabBtn.on('click', function() { $frameTabBtn.addClass('is-active'); $cliponTabBtn.removeClass('is-active'); $frameFieldsBlock.show(); $cliponFieldsBlock.hide(); }); $cliponTabBtn.on('click', function() { $cliponTabBtn.addClass('is-active'); $frameTabBtn.removeClass('is-active'); $cliponFieldsBlock.show(); $frameFieldsBlock.hide(); }); // Calibration mode switch $calibrationSwitch.on('click', function() { const wasOn = $(this).hasClass('is-on'); $(this).toggleClass('is-on'); calibrationMode = wasOn ? 'fine' : 'coarse'; }); // D-pad $('#panel-cal .dpad [data-act]').on('click', function(){ const a = $(this).data('act'); const inc = getIncrements(); if (a==='up') state.ty -= inc.step; else if (a==='down') state.ty += inc.step; else if (a==='left') state.tx -= inc.step; else if (a==='right') state.tx += inc.step; else if (a==='center') { state.tx=0; state.ty=0; } applyTransform(); if ((activeSize||'')===baseSizeOnUpload && imageUploaded) baseCalibrated = !isDefaultTransform(); maybeEnableTrace(); }); // Strips $('#panel-cal .strip').on('click', 'button', function(){ const $strip = $(this).closest('.strip'); const plus = $(this).hasClass('plus'); const kind = $strip.data('strip'); const inc = getIncrements(); if (kind==='zoom') state.scale = clamp(state.scale + (plus? inc.zoom : -inc.zoom), 0.05, 50); if (kind==='scaleX') state.sx = clamp(state.sx + (plus? inc.sx : -inc.sx), 0.05, 50); if (kind==='scaleY') state.sy = clamp(state.sy + (plus? inc.sy : -inc.sy), 0.05, 50); if (kind==='rotate') { const v = plus ? state.rot + inc.rot : state.rot - inc.rot; state.rot = ((v + 180) % 360 + 360) % 360 - 180; } applyTransform(); if ((activeSize||'')===baseSizeOnUpload && imageUploaded) baseCalibrated = !isDefaultTransform(); maybeEnableTrace(); }); // ---------- Guides ---------- function mmToPx(mm){ const dpi = getDpi(); return (mm||0) * (dpi/25.4); } function getDpi(){ const v = parseFloat($frame.DPI.val()); return (isFinite(v)&&v>0)? v : 350; } function anyGuideValues(){ return ['A','B','Br','H'].some(k => parseFloat($frame[k].val())>0); } function updateGuides(){ if (!$wrap.is(':visible')) return; const show = $tabCal.hasClass('is-active') && anyGuideValues(); $guides.toggle(show); if (!show) return; const a = parseFloat($frame.A.val()) || 0; const b = parseFloat($frame.B.val()) || 0; const br = parseFloat($frame.Br.val())|| 0; const h = parseFloat($frame.H.val()) || 0; const mat= ($frame.Mat.val()||'plastic').toLowerCase(); const col= $frame.Col.val() || '#008000'; $guides.find('.g').css('background', col); const H = $box.innerHeight(); const centerY = H/2; const spacing12 = (mat==='plastic') ? 1.9 : (mat==='metal' ? 1.2 : 1); const aOffset = (mat==='plastic') ? 0.8 : (mat==='metal' ? 0.4 : 0); const v1 = br ? mmToPx(br/2) : null; const v2 = (v1!=null) ? v1 + mmToPx(spacing12) : null; const v3 = (v2!=null && a) ? v2 + mmToPx(Math.max(a - aOffset, 0)) : null; placeV($v1, v1); placeV($v2, v2); placeV($v3, v3); const bOff = b ? mmToPx(b/2) : null; const hOff = h ? mmToPx(h/2) : null; placeH($h1, b ? centerY - bOff : null); placeH($h2, b ? centerY + bOff : null); placeH($h3, h ? centerY - hOff : null); placeH($h4, h ? centerY + hOff : null); } function placeV($el, rightPx){ if(!$el.length){return;} if(rightPx==null){$el.hide();return;} $el.show().css({right: Math.round(rightPx)+'px', left:'auto', top:0, height:'100%'}); } function placeH($el, topPx){ if(!$el.length){return;} if(topPx==null){$el.hide();return;} $el.show().css({top: Math.round(topPx)+'px', left:0, width:'100%'}); } // Snap to the right-most vertical guideline (v1) function v1X(){ const wrapEl = $wrap[0]; if (!wrapEl) return 0; // Prefer actual DOM position if v1 is visible if ($v1.length && $v1[0].offsetParent) { const wrapRect = wrapEl.getBoundingClientRect(); const v1Rect = $v1[0].getBoundingClientRect(); return Math.max(0, v1Rect.left - wrapRect.left); } // Fallback: compute from Bridge (br/2) if guides are hidden const W = wrapEl.getBoundingClientRect().width; const br = parseFloat($frame.Br.val()) || 0; if (!(br > 0)) return W; // no bridge -> far right const xFromRight = mmToPx(br / 2); return Math.max(0, W - xFromRight); } $panels.cal.on('input change', '.clipon-eye-a, .clipon-eye-b, .clipon-bridge, .clipon-height, .clipon-material, .clipon-dpi, .clipon-color', updateGuides); // ---------- Tabs (Calibrate / Trace) ---------- function captureTransformedImage(forceOverwrite) { const currentSize = activeSize || 'base'; const $sizeImagesInput = $hidden.find('input[name="_clipon_size_images"]'); const sizeImages = readJSON($sizeImagesInput.val(), {}); // Check if image already exists and we're not forcing overwrite if (sizeImages[currentSize] && !forceOverwrite && !isDefaultTransform()) { console.log('Image already exists for', currentSize, '- skipping bake'); activateTab('trace'); return; } if (isDefaultTransform() && sizeImages[currentSize]) { activateTab('trace'); return; } const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); img.onload = function() { canvas.width = $wrap.width(); canvas.height = $wrap.height(); // Fill with white background for JPEG ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(canvas.width/2, canvas.height/2); ctx.translate(state.tx, state.ty); ctx.rotate(state.rot * Math.PI / 180); ctx.scale(state.sx * state.scale, state.sy * state.scale); ctx.translate(-img.width/2, -img.height/2); ctx.drawImage(img, 0, 0); ctx.restore(); canvas.toBlob(function(blob) { if (!blob) return; const reader = new FileReader(); reader.onloadend = function() { const base64data = reader.result; $.ajax({ url: CLIPON_VARS.ajaxUrl, type: 'POST', data: { action: 'clipon_upload_size_image', nonce: CLIPON_VARS.uploadNonce, post_id: CLIPON_VARS.postId, size: currentSize, image_data: base64data }, success: function(response) { if (response.success && response.data && response.data.url) { sizeImages[currentSize] = response.data.url; $sizeImagesInput.val(JSON.stringify(sizeImages)); $img.attr('src', response.data.url); $hid.url.val(response.data.url); $hid.id.val(response.data.attachment_id); console.log('✓ Baked image for size:', currentSize, '→', response.data.url); // Save current transform before resetting const savedTransform = { tx: state.tx, ty: state.ty, rot: state.rot, scale: state.scale, sx: state.sx, sy: state.sy }; // Reset transforms resetTransform(); applyTransform(); // Transform path coordinates using the saved transform const key = currentSize || '_single'; const ts = traceBySize.get(key); if (ts && ts.points && ts.points.length > 0) { transformPathCoordinates(ts, savedTransform); persistAll(); } activateTab('trace'); } }, error: function() { console.error('Failed to upload transformed image'); } }); }; reader.readAsDataURL(blob); }, 'image/jpeg', 0.85); }; img.src = $hid.url.val(); } function activateTab(key){ // If entering Trace mode from Calibrate with transforms, bake image first if ($tabCal.hasClass('is-active') && key === 'trace') { const hasTransforms = !isDefaultTransform(); // If transforms exist, always bake and transform coordinates if (hasTransforms) { const $sizeImagesInput = $hidden.find('input[name="_clipon_size_images"]'); const sizeImages = readJSON($sizeImagesInput.val(), {}); const currentSize = activeSize || 'base'; const imageExists = !!sizeImages[currentSize]; captureTransformedImage(imageExists); // forceOverwrite if image exists return; } } $tabs.removeClass('is-active'); $tabs.filter('[data-panel="'+key+'"]').addClass('is-active'); Object.keys($panels).forEach(k => $panels[k].toggleClass('is-active', k===key)); if (key==='cal') { $guides.toggle(anyGuideValues()); $svg.css('pointer-events','none'); syncPathWithImage(); showIdleView(); } else if (key==='trace') { $guides.hide(); $svg.css('pointer-events','auto'); $svg.css('transform', 'none'); const ts = traceBySize.get(activeSize||'_single'); if (!ts) { beginPlacingTrace(activeSize||'_single'); } else { // Path already exists - just show it with edit controls, don't redraw $svg.css('display', 'block'); updateTraceVisibility(); } } else { $guides.hide(); $svg.css('pointer-events','auto'); showIdleView(); } } $tabs.on('click', function () { if (!$(this).is('[disabled]')) activateTab($(this).data('panel')); }); if ($editBtn.length) $editBtn.on('click', ()=>activateTab('cal')); if ($traceBtn.length) $traceBtn.on('click', ()=>{ if (!$traceBtn.is('[disabled]')) activateTab('trace'); }); // ---------- Size tabs ---------- $sizeTabs.on('click', function(){ const $t = $(this); if ($t.hasClass('is-active')) return; // Auto-generate paths for all sizes before switching (if current size has a completed path) const currentKey = (activeSize || '_single'); const currentTs = traceBySize.get(currentKey); if (currentTs && currentTs.points && currentTs.points.length >= 4 && $sizeTabs.length > 1) { // Check if we haven't already generated for all sizes let needsGeneration = false; $sizeTabs.each(function() { const sz = $(this).data('size'); if (sz !== activeSize && !traceBySize.has(sz)) { needsGeneration = true; return false; // break } }); if (needsGeneration) { generatePathsForAllSizes(currentKey); } } // Generate scaled images for other sizes if current size has a baked image const $sizeImagesInput = $hidden.find('input[name="_clipon_size_images"]'); const sizeImages = readJSON($sizeImagesInput.val(), {}); const currentSize = activeSize || 'base'; if (sizeImages[currentSize]) { generateScaledImagesForOtherSizes(); } // Only persist if there's an image uploaded (avoid JSON parse errors on empty data) if (imageUploaded) { persistAll(); } $sizeTabs.removeClass('is-active').attr('aria-selected','false'); $t.addClass('is-active').attr('aria-selected','true'); const next = $t.data('size'); $hid.activeSz.val(next); activeSize = next; loadSize(next); }); function generateScaledImagesForOtherSizes() { const $sizeImagesInput = $hidden.find('input[name="_clipon_size_images"]'); const sizeImages = readJSON($sizeImagesInput.val(), {}); const currentSize = activeSize || 'base'; // Get all size tabs const $sizeTabs = $root.find('.clipon-size-tab'); const sizes = []; $sizeTabs.each(function() { const sz = $(this).data('size'); if (sz && sz !== currentSize && !sizeImages[sz]) { sizes.push(sz); } }); if (sizes.length === 0) { console.log('All size images already exist'); return; } // Get current size A value const currentAB = parseAB(currentSize); const currentA = currentAB.a || baseAOnUpload || parseFloat($frame.A.val()) || 0; if (currentA <= 0) { console.log('Cannot generate sized images: current A value is 0'); return; } const currentImageUrl = sizeImages[currentSize] || $hid.url.val(); if (!currentImageUrl) { console.log('No source image to generate from'); return; } const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); img.onload = function() { canvas.width = $wrap.width(); canvas.height = $wrap.height(); // Fill with white background for JPEG ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const uploadPromises = []; sizes.forEach(function(sizeLabel) { const targetAB = parseAB(sizeLabel); const targetA = targetAB.a; if (!targetA || targetA <= 0) return; const scaleFactor = targetA / currentA; const scaledCanvas = document.createElement('canvas'); const scaledCtx = scaledCanvas.getContext('2d'); scaledCanvas.width = canvas.width; scaledCanvas.height = canvas.height; // Fill with white background for JPEG scaledCtx.fillStyle = '#FFFFFF'; scaledCtx.fillRect(0, 0, scaledCanvas.width, scaledCanvas.height); scaledCtx.save(); scaledCtx.translate(canvas.width / 2, canvas.height / 2); scaledCtx.scale(scaleFactor, scaleFactor); scaledCtx.translate(-canvas.width / 2, -canvas.height / 2); scaledCtx.drawImage(canvas, 0, 0); scaledCtx.restore(); scaledCanvas.toBlob(function(blob) { const reader = new FileReader(); reader.onloadend = function() { uploadPromises.push( uploadImageToMediaLibrary(sizeLabel, reader.result).then(function(result) { if (result.success && result.data && result.data.url) { sizeImages[sizeLabel] = result.data.url; console.log('✓ Generated image for size:', sizeLabel, '→', result.data.url); } }).catch(function(err) { console.error('Failed to upload image for size:', sizeLabel, err); }) ); }; reader.readAsDataURL(blob); }, 'image/jpeg', 0.85); }); Promise.all(uploadPromises).then(function() { $sizeImagesInput.val(JSON.stringify(sizeImages)); console.log('✓ All size images generated'); }); }; img.src = currentImageUrl; } function uploadImageToMediaLibrary(size, imageData) { return new Promise(function(resolve, reject) { $.ajax({ url: CLIPON_VARS.ajaxUrl, type: 'POST', data: { action: 'clipon_upload_size_image', nonce: CLIPON_VARS.uploadNonce, post_id: CLIPON_VARS.postId, size: size, image_data: imageData }, success: function(response) { resolve(response); }, error: function(xhr, status, error) { reject(error); } }); }); } function parseAB(lbl){ // Handle the specific square symbol ▢ used in your size labels const m = String(lbl||'').match(/(\d+(?:\.\d+)?)\s*▢\s*(\d+(?:\.\d+)?)/u); return m ? { a:+m[1], bridge:+m[2] } : {a:0, bridge:0}; } const sizeMap = (function(){ try { return JSON.parse($hid.sizesMap.val() || '{}') || {}; } catch(_){ return {}; } })(); function persistAll(){ persistTransform(); persistFrame(); persistTrace(); persistDrill(); persistAttributes(); const key = (activeSize||'_single'); // Safely parse JSON, use defaults if parsing fails try { sizeMap[key] = { transform: JSON.parse($hid.transform.val() || '{}') || {}, frame: JSON.parse($hid.frame.val() || '{}') || {}, path: $hid.path.val() || '', points: JSON.parse($hid.points.val() || '[]') || [], handles: JSON.parse($hid.handles.val() || '[]') || [], drill: JSON.parse($hid.drill.val() || '{}') || {}, attributes: readAttributes() }; $hid.sizesMap.val(JSON.stringify(sizeMap)); } catch(e) { console.warn('Error persisting data:', e); } } function readAttributes() { const attrs = {}; // Read all position fields (both selects and static values) $('.clipon-position-field').each(function() { const $field = $(this); const pos = $field.data('position'); if (!pos) return; // Try to read from select dropdown first const $select = $field.find('.clipon-position-select'); if ($select.length) { const val = $select.val(); if (val) attrs[pos] = val; } else { // If no select, read from static value div const $valueDiv = $field.find('.clipon-position-value'); if ($valueDiv.length) { const val = $valueDiv.text().trim(); if (val && val !== '—') attrs[pos] = val; } } }); // Read magnetic bridge (select or static) const $bridgeField = $('.clipon-bridge-field'); const $bridgeSelect = $bridgeField.find('.clipon-bridge-select'); if ($bridgeSelect.length) { const val = $bridgeSelect.val(); if (val) attrs['magnetic-bridge'] = val; } else { const $bridgeValue = $bridgeField.find('.clipon-position-value'); if ($bridgeValue.length) { const val = $bridgeValue.text().trim(); if (val && val !== '—') attrs['magnetic-bridge'] = val; } } return attrs; } function applyAttributes(attrs) { if (!attrs) return; $('.clipon-position-select').each(function() { const pos = $(this).data('position'); if (attrs[pos]) $(this).val(attrs[pos]); }); if (attrs['magnetic-bridge']) { $('.clipon-bridge-select').val(attrs['magnetic-bridge']); } } function persistAttributes() { // Attributes are saved in persistAll -> sizeMap } // ---- controls format helpers (convert old -> per-anchor {hin,hout}) ---- function normalizeControls(handles, points){ if (!handles) return null; const n = (points||[]).length; if (!n) return null; // already per-anchor? if (Array.isArray(handles) && handles.length && handles[0] && (handles[0].hin && handles[0].hout)) return handles; // legacy: [{cp1,cp2}] meaning cp1 is outgoing from P_i, cp2 is incoming to P_(i+1) if (Array.isArray(handles) && handles[0] && handles[0].cp1 && handles[0].cp2) { return points.map((_, i) => ({ hin: { x:+handles[(i-1+n)%n].cp2.x, y:+handles[(i-1+n)%n].cp2.y }, hout: { x:+handles[i].cp1.x, y:+handles[i].cp1.y } })); } // earlier segment form {c1Seg, c2Seg} if (handles.c1Seg && handles.c2Seg) { return points.map((_, i) => ({ hin: { x:+handles.c2Seg[(i-1+n)%n].x, y:+handles.c2Seg[(i-1+n)%n].y }, hout: { x:+handles.c1Seg[i].x, y:+handles.c1Seg[i].y } })); } return null; } // Build per-anchor handles from Catmull-Rom (natural cubic) function catmullHandles(points, tension = 1/6){ const n = points.length; const c1Seg = new Array(n), c2Seg = new Array(n); for (let i=0;i{ d += (idx===0 ? `M ${p.x},${p.y}` : ` L ${p.x},${p.y}`); }); $path.attr('d', d); } showMaskForPlaced(sizeKey); persistTrace(); $svg.css({ display:'block', pointerEvents:'auto' }); return; } // edit $path.attr('d', buildPathD(ts.points, ts.handles)); const n = ts.points.length; for (let i=0;i=midX && clientY>=midY) return 'br'; if (clientX< midX && clientY>=midY) return 'bl'; if (clientX< midX && clientY< midY) return 'tl'; return 'tr'; } // place points only in revealed quarter $wrap.on('click', function(e){ if (!$tabTrace.hasClass('is-active')) return; const key = (activeSize||'_single'); let ts = traceBySize.get(key); if (!ts || ts.mode!=='placing') return; const want = QUADS[ts.points.length]; const q = whichQuarter(e.clientX, e.clientY); if (q !== want) return; const pt = pointerXY(e); ts.points.push(pt); drawTrace(key); persistTrace(); if (ts.points.length>=4) { ts.mode='edit'; ts.handles = catmullHandles(ts.points); hideMask(); $traceMsg.hide(); drawTrace(key); persistTrace(); $download.show(); } }); // Generate scaled paths and images for all sizes based on the base size function generatePathsForAllSizes(baseSizeKey) { const baseTs = traceBySize.get(baseSizeKey); if (!baseTs || !baseTs.points || baseTs.points.length < 4) return; // Get base size A value const baseLabel = baseSizeKey === '_single' ? activeSize : baseSizeKey; const baseAB = parseAB(baseLabel); const baseA = baseAB.a || baseAOnUpload || parseFloat($frame.A.val()) || 0; if (baseA <= 0) return; const centerX = $wrap.width() / 2; const centerY = $wrap.height() / 2; // Get base data from sizeMap const baseData = sizeMap[baseSizeKey] || { transform: { tx: state.tx, ty: state.ty, scale: state.scale, sx: state.sx, sy: state.sy, rot: state.rot }, frame: { a: parseFloat($frame.A.val()) || 0, b: parseFloat($frame.B.val()) || 0, bridge: parseFloat($frame.Br.val()) || 0, h: parseFloat($frame.H.val()) || 0, material: $frame.Mat.val() || 'plastic', dpi: getDpi(), color: $frame.Col.val() || '#008000' }, points: baseTs.points, handles: baseTs.handles, drill: baseTs.drillData || {}, attributes: readAttributes() }; // Loop through all size tabs $sizeTabs.each(function() { const targetLabel = $(this).data('size'); if (targetLabel === baseLabel) return; // Skip the base size itself // Get target A value from size label const targetAB = parseAB(targetLabel); const targetA = targetAB.a || 0; if (targetA <= 0) return; // Calculate scale factor (works for both larger AND smaller sizes) const k = targetA / baseA; // Scale transform const scaledTransform = Object.assign({}, baseData.transform || {}); scaledTransform.scale = +((scaledTransform.scale || 1) * k).toFixed(6); // Scale points const scaledPoints = baseTs.points.map(p => ({ x: centerX + (p.x - centerX) * k, y: centerY + (p.y - centerY) * k })); // Scale handles const scaledHandles = baseTs.handles.map(h => ({ hin: { x: centerX + (h.hin.x - centerX) * k, y: centerY + (h.hin.y - centerY) * k }, hout: { x: centerX + (h.hout.x - centerX) * k, y: centerY + (h.hout.y - centerY) * k } })); // Generate scaled path const scaledPath = buildPathD(scaledPoints, scaledHandles); // Create frame data for this size const scaledFrame = Object.assign({}, baseData.frame || {}, { a: targetA, bridge: targetAB.bridge }); // Store in sizeMap sizeMap[targetLabel] = { transform: scaledTransform, frame: scaledFrame, path: scaledPath, points: scaledPoints, handles: scaledHandles, drill: baseData.drill || {}, attributes: baseData.attributes || {} }; // Also create in traceBySize for immediate use traceBySize.set(targetLabel, { mode: 'edit', points: scaledPoints, handles: scaledHandles, drillOpen: baseTs.drillOpen || false, magMode: baseTs.magMode || 'spring', springBackup: null, drillData: baseTs.drillData || { mode: 'spring', leftTopX: 20, leftBottomX: 20, bridge: 10, factorSpring: 44, factorMag: 2.25 } }); }); // Persist the updated sizeMap $hid.sizesMap.val(JSON.stringify(sizeMap)); console.log('✓ Generated scaled paths for all sizes based on:', baseLabel); // Now generate and upload scaled images for all sizes generateAndUploadSizeImages(baseSizeKey); } // Generate scaled images for all sizes and upload to media library function generateAndUploadSizeImages(baseSizeKey) { console.log('Generating scaled images for all sizes...'); const $sizeImagesInput = $hidden.find('input[name="_clipon_size_images"]'); const existingSizeImages = readJSON($sizeImagesInput.val(), {}); const sizeImages = Object.assign({}, existingSizeImages); // Get all size buttons const $sizeTabs = $root.find('.clipon-size-tab'); const sizes = []; $sizeTabs.each(function() { sizes.push($(this).data('size')); }); if (sizes.length === 0) return; // Get base size dimensions const baseLabel = baseSizeKey === '_single' ? activeSize : baseSizeKey; const baseAB = parseAB(baseLabel); const baseA = baseAB.a || baseAOnUpload || parseFloat($frame.A.val()) || 0; if (baseA <= 0) { console.log('Cannot generate sized images: base A value is 0'); return; } // Get current transformed/calibrated image const currentImg = $img[0]; if (!currentImg || !currentImg.src || currentImg.src.includes('data:image')) { console.log('Using current displayed image for size generation'); } // Create a canvas with the current view const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // Set canvas size to match wrapper const wrapWidth = $wrap.width(); const wrapHeight = $wrap.height(); canvas.width = wrapWidth; canvas.height = wrapHeight; // Fill with white background for JPEG ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, wrapWidth, wrapHeight); // Draw the current state (image with transformations) const t = readTransform(); ctx.save(); ctx.translate(wrapWidth / 2, wrapHeight / 2); ctx.rotate((t.rotation || 0) * Math.PI / 180); ctx.scale(t.scaleX || 1, t.scaleY || 1); ctx.scale(t.scale || 1, t.scale || 1); ctx.translate(t.tx || 0, t.ty || 0); ctx.translate(-wrapWidth / 2, -wrapHeight / 2); ctx.drawImage(currentImg, 0, 0, wrapWidth, wrapHeight); ctx.restore(); // Now create scaled versions for each size const uploadPromises = []; sizes.forEach(sizeLabel => { const targetAB = parseAB(sizeLabel); const targetA = targetAB.a; if (!targetA || targetA <= 0) return; // Calculate scale factor const scaleFactor = targetA / baseA; // Create scaled canvas const scaledCanvas = document.createElement('canvas'); const scaledCtx = scaledCanvas.getContext('2d'); scaledCanvas.width = wrapWidth; scaledCanvas.height = wrapHeight; // Fill with white background for JPEG scaledCtx.fillStyle = '#FFFFFF'; scaledCtx.fillRect(0, 0, wrapWidth, wrapHeight); // Draw scaled image scaledCtx.save(); scaledCtx.translate(wrapWidth / 2, wrapHeight / 2); scaledCtx.scale(scaleFactor, scaleFactor); scaledCtx.translate(-wrapWidth / 2, -wrapHeight / 2); scaledCtx.drawImage(canvas, 0, 0); scaledCtx.restore(); // Convert to blob and upload scaledCanvas.toBlob(function(blob) { if (!blob) return; // Convert blob to base64 const reader = new FileReader(); reader.onloadend = function() { const base64data = reader.result; // Upload to media library uploadPromises.push( uploadImageToMediaLibrary(sizeLabel, base64data).then(function(result) { if (result.success && result.data && result.data.url) { sizeImages[sizeLabel] = result.data.url; console.log('✓ Uploaded image for size:', sizeLabel, '→', result.data.url); } }).catch(function(err) { console.error('Failed to upload image for size:', sizeLabel, err); }) ); }; reader.readAsDataURL(blob); }, 'image/jpeg', 0.85); }); // Wait for all uploads to complete and save Promise.all(uploadPromises).then(function() { $sizeImagesInput.val(JSON.stringify(sizeImages)); console.log('✓ All size images uploaded and saved'); }); } // Upload image to WordPress media library via AJAX function uploadImageToMediaLibrary(size, imageData) { return new Promise(function(resolve, reject) { $.ajax({ url: CLIPON_VARS.ajaxUrl, type: 'POST', data: { action: 'clipon_upload_size_image', nonce: CLIPON_VARS.uploadNonce, post_id: CLIPON_VARS.postId, size: size, image_data: imageData }, success: function(response) { resolve(response); }, error: function(xhr, status, error) { reject(error); } }); }); } function enableDragging(sizeKey){ const ts = traceBySize.get(sizeKey); if (!ts) return; let drag = null; let lastClickTime = 0; let lastClickIndex = null; // Start drag (or detect double-click) $svg.off('.dragAnch').on('pointerdown.dragAnch', '.anchor, .cp', function (e) { const idx = +this.getAttribute('data-index'); const kind = this.classList.contains('anchor') ? 'anchor' : this.getAttribute('data-kind'); // 'hin'|'hout' // Double-click detection for anchor points if (kind === 'anchor') { const now = Date.now(); const timeSinceLastClick = now - lastClickTime; // If same anchor clicked within 300ms, it's a double-click if (idx === lastClickIndex && timeSinceLastClick < 300) { e.preventDefault(); e.stopPropagation(); // Don't allow removing the first 4 points if (idx >= 4) { removePointFromPath(idx, sizeKey); } // Reset click tracking lastClickTime = 0; lastClickIndex = null; return; } // Track this click lastClickTime = now; lastClickIndex = idx; } // Normal drag behavior drag = { kind, idx }; e.preventDefault(); }); // Dragging $(window).off('.dragAnch').on('pointermove.dragAnch', function (e) { if (!drag) return; const i = drag.idx; const pt = pointerXY(e); if (drag.kind === 'anchor') { let newX = pt.x; let newY = pt.y; // TR in Magnetic: X locked to v1X(), Y free var trIdx = findTRIndex(ts); if (ts.magMode === 'magnetic' && i === trIdx) { newX = v1X(); } const old = ts.points[i]; const dx = newX - old.x; const dy = newY - old.y; ts.points[i] = { x: newX, y: newY }; ts.handles[i].hin = { x: ts.handles[i].hin.x + dx, y: ts.handles[i].hin.y + dy }; ts.handles[i].hout = { x: ts.handles[i].hout.x + dx, y: ts.handles[i].hout.y + dy }; } else { // dragging a control point let x = pt.x, y = pt.y; // In Magnetic mode, keep TR outgoing control ('hout') on v1 vertical var trIdx = findTRIndex(ts); const isTRMagneticHout = (ts.magMode === 'magnetic' && i === trIdx && drag.kind === 'hout'); const isTRMagnetic = (ts.magMode === 'magnetic' && i === trIdx); if (isTRMagneticHout) { x = v1X(); } // Update the dragged handle ts.handles[i][drag.kind] = { x, y }; // CONSTRAINT: Keep handles collinear (on the same line through anchor) // Exception: Skip for TR point in magnetic mode (both handles should be free) if (!isTRMagnetic) { const anchor = ts.points[i]; const oppositeKind = drag.kind === 'hin' ? 'hout' : 'hin'; const oppositeHandle = ts.handles[i][oppositeKind]; // Calculate vector from anchor to dragged handle const dx = x - anchor.x; const dy = y - anchor.y; const dragDist = Math.sqrt(dx * dx + dy * dy); // Calculate original distance of opposite handle from anchor const oppDx = oppositeHandle.x - anchor.x; const oppDy = oppositeHandle.y - anchor.y; const oppDist = Math.sqrt(oppDx * oppDx + oppDy * oppDy); // Mirror the direction: opposite handle stays on same line but opposite side if (dragDist > 0) { ts.handles[i][oppositeKind] = { x: anchor.x - (dx / dragDist) * oppDist, y: anchor.y - (dy / dragDist) * oppDist }; } } } // Redraw path + handles $path.attr('d', buildPathD(ts.points, ts.handles)); svgClear($gLines); svgClear($gCtrl); svgClear($gAnch); const n = ts.points.length; for (let k=0;kRemove Point' + ''; $('body').append(menuHtml); console.log('Context menu created'); } var $contextMenu = $('#clipon-context-menu'); var contextMenuIndex = null; var contextMenuSizeKey = null; // Right-click on anchor points to show context menu $svg.on('contextmenu', '.anchor', function(e) { console.log('Right-click detected on anchor'); e.preventDefault(); e.stopPropagation(); var idx = +$(this).attr('data-index'); console.log('Anchor index:', idx); // Don't show menu for the first 4 core points if (idx < 4) { console.log('Skipping - core point'); return; } contextMenuIndex = idx; contextMenuSizeKey = activeSize || '_single'; // Position menu at click location console.log('Showing menu at:', e.clientX, e.clientY); $contextMenu.css({ left: e.clientX + 'px', top: e.clientY + 'px', display: 'block' }); }); // Click on Remove Point button $('#clipon-remove-point').on('click', function(e) { console.log('Remove clicked'); e.preventDefault(); e.stopPropagation(); if (contextMenuIndex !== null && contextMenuSizeKey !== null) { removePointFromPath(contextMenuIndex, contextMenuSizeKey); } $contextMenu.hide(); contextMenuIndex = null; contextMenuSizeKey = null; }); // Hide context menu when clicking elsewhere $(document).on('click', function(e) { if (!$(e.target).closest('#clipon-context-menu').length) { $contextMenu.hide(); } }); // Also hide when scrolling $(window).on('scroll', function() { $contextMenu.hide(); }); function addPointToPath(x, y, sizeKey) { const ts = traceBySize.get(sizeKey); if (!ts || !ts.points || ts.points.length < 4) return; // Find the closest point ON the curve (not clicked position) const pathEl = document.getElementById('clipon-trace-path'); const closestInfo = findClosestPointOnCurve(pathEl, x, y); if (!closestInfo) return; // Use the exact point on the curve const exactPoint = closestInfo.point; const insertIdx = closestInfo.segmentIndex + 1; // Get tangent at this point to calculate handles const tangent = tangentUnitAt(pathEl, closestInfo.lengthOnPath); // Calculate handle length based on neighboring points const prevIdx = closestInfo.segmentIndex; const nextIdx = (insertIdx) % ts.points.length; const prevPoint = ts.points[prevIdx]; const nextPoint = ts.points[nextIdx]; // Average distance to neighbors const distToPrev = Math.hypot(exactPoint.x - prevPoint.x, exactPoint.y - prevPoint.y); const distToNext = Math.hypot(exactPoint.x - nextPoint.x, exactPoint.y - nextPoint.y); const avgDist = (distToPrev + distToNext) / 2; // Handle length proportional to distance (typically 1/3 to 1/2) const handleLength = avgDist * 0.35; // Create handles along the tangent const newHandles = { hin: { x: exactPoint.x - tangent.x * handleLength, y: exactPoint.y - tangent.y * handleLength }, hout: { x: exactPoint.x + tangent.x * handleLength, y: exactPoint.y + tangent.y * handleLength } }; // Insert the new point ts.points.splice(insertIdx, 0, exactPoint); ts.handles.splice(insertIdx, 0, newHandles); // Update both spring and magnetic paths if they exist if (ts.springPath) { ts.springPath.points = JSON.parse(JSON.stringify(ts.points)); ts.springPath.handles = JSON.parse(JSON.stringify(ts.handles)); } if (ts.magneticPath) { ts.magneticPath.points = JSON.parse(JSON.stringify(ts.points)); ts.magneticPath.handles = JSON.parse(JSON.stringify(ts.handles)); } // Redraw drawTrace(sizeKey); } function removePointFromPath(index, sizeKey) { const ts = traceBySize.get(sizeKey); if (!ts || !ts.points || index < 4 || index >= ts.points.length) return; // Remove the point and its handles ts.points.splice(index, 1); ts.handles.splice(index, 1); // Only regenerate handles for neighboring points to minimize curve change // Regenerate for the previous point (now at index-1) and next point (now at index, which was index+1) const numPoints = ts.points.length; if (numPoints >= 4) { // Use catmullHandles to regenerate just the affected handles const allHandles = catmullHandles(ts.points); // Update only the affected handles (previous and next points) const prevIdx = (index - 1 + numPoints) % numPoints; const currIdx = index % numPoints; // This is the point that was after the removed one ts.handles[prevIdx] = allHandles[prevIdx]; if (currIdx < ts.handles.length) { ts.handles[currIdx] = allHandles[currIdx]; } } // Update both spring and magnetic paths if they exist if (ts.springPath) { ts.springPath.points = JSON.parse(JSON.stringify(ts.points)); ts.springPath.handles = JSON.parse(JSON.stringify(ts.handles)); } if (ts.magneticPath) { ts.magneticPath.points = JSON.parse(JSON.stringify(ts.points)); ts.magneticPath.handles = JSON.parse(JSON.stringify(ts.handles)); } // Redraw drawTrace(sizeKey); } function findClosestPointOnCurve(pathEl, x, y) { if (!pathEl) return null; const totalLength = pathEl.getTotalLength(); const samples = 200; let minDist = Infinity; let bestLength = 0; // Sample the path to find closest point for (let i = 0; i <= samples; i++) { const len = (totalLength * i) / samples; const pt = pathEl.getPointAtLength(len); const dist = Math.hypot(pt.x - x, pt.y - y); if (dist < minDist) { minDist = dist; bestLength = len; } } // Get the exact point on the curve at this length const exactPoint = pathEl.getPointAtLength(bestLength); // Find which segment this corresponds to const ts = traceBySize.get(activeSize || '_single'); if (!ts || !ts.points) return null; const numPoints = ts.points.length; const segmentIndex = Math.floor((bestLength / totalLength) * numPoints) % numPoints; return { point: exactPoint, lengthOnPath: bestLength, segmentIndex: segmentIndex, distance: minDist }; } // ---------- Drill maths + drawing ---------- function tangentUnitAt(pathEl, s){ const L = pathEl.getTotalLength(); const h = Math.max(0.5, L/2000); const s1 = Math.max(0, s - h); const s2 = Math.min(L, s + h); const p1 = pathEl.getPointAtLength(s1); const p2 = pathEl.getPointAtLength(s2); let tx = p2.x - p1.x, ty = p2.y - p1.y; const len = Math.hypot(tx, ty) || 1e-6; return { x: tx/len, y: ty/len }; } function inwardNormalUnitAt(pathEl, s, centerX, centerY){ const t = tangentUnitAt(pathEl, s); const n1 = { x: -t.y, y: t.x }; const n2 = { x: t.y, y: -t.x }; const p = pathEl.getPointAtLength(s); const toC= { x: centerX - p.x, y: centerY - p.y }; const dot1 = n1.x*toC.x + n1.y*toC.y; const dot2 = n2.x*toC.x + n2.y*toC.y; return (dot1 >= dot2) ? n1 : n2; } function samplePathForTargetXY(pathEl, target, axis, sign, centerX, centerY){ const N = 2000, L = pathEl.getTotalLength(); let best = null, bestErr = Infinity; for(let i=0;i<=N;i++){ const s = (L*i)/N; const pt = pathEl.getPointAtLength(s); const v = (axis==='x') ? pt.x : pt.y; const otherRel = (axis==='x') ? (pt.y - centerY) : (pt.x - centerX); if (sign && Math.sign(otherRel) !== sign) continue; const err = Math.abs(v - target); if (err < bestErr){ bestErr = err; best = { x:pt.x, y:pt.y, s }; } } return best; } // Step 1–4: compute drills only when visible function drillsVisible(ts){ const hasPath = !!($path.attr('d') || '').trim(); const isTracePanel = $tabTrace.hasClass('is-active'); return hasPath && (ts.drillOpen || !isTracePanel); } function drawDrills(){ const ts = traceBySize.get(activeSize||'_single'); svgClear($gDrills); if (!ts || !drillsVisible(ts)) return; const d = ($path.attr('d')||'').trim(); if (!d) return; const pathEl = $path[0]; const bbox = pathEl.getBBox(); if (!bbox || !bbox.width || !bbox.height) return; const centerX = bbox.x + bbox.width/2; const centerY = bbox.y + bbox.height/2; const dpi = getDpi(); const pxPerMm = dpi / 25.4; const leftTopXmm = parseFloat($drLeftTop.val()) || 20; const leftBottomXmm = parseFloat($drLeftBottom.val()) || 20; const bridgeMm = parseFloat($drBridge.val()) || 10; const isMag = (ts.magMode === 'magnetic'); // LEFT slots (top & bottom) — draw 1.0mm inward along normal at independent X positions const targetTopXpx = centerX - (leftTopXmm * pxPerMm); const targetBotXpx = centerX - (leftBottomXmm * pxPerMm); const topPt = samplePathForTargetXY(pathEl, targetTopXpx, 'x', -1, centerX, centerY); const botPt = samplePathForTargetXY(pathEl, targetBotXpx, 'x', +1, centerX, centerY); function drawNormalSlotAt(sample, mmLen){ if(!sample) return; const n = inwardNormalUnitAt(pathEl, sample.s, centerX, centerY); const dx = n.x * (mmLen * pxPerMm); const dy = n.y * (mmLen * pxPerMm); svgAppend($gDrills, svgEl('line', { x1:sample.x, y1:sample.y, x2:sample.x+dx, y2:sample.y+dy, stroke:HOLE_COLOR, 'stroke-width':2 })); } drawNormalSlotAt(topPt, 1.0); drawNormalSlotAt(botPt, 1.0); // RIGHT if (isMag) { // Magnetic → horizontal slot at y = centerY - bridgeMm, ending at v1X() const y = centerY - (bridgeMm * pxPerMm); const x = v1X(); const lenPx = 2.0 * pxPerMm; svgAppend($gDrills, svgEl('line', { x1:x - lenPx, y1:y, x2:x, y2:y, stroke:HOLE_COLOR, 'stroke-width':2 })); } else { // Spring → short slot + small hole near right const rightEdgePx = bbox.x + bbox.width; const startXpx = rightEdgePx - (10.5 * pxPerMm); const startPt = samplePathForTargetXY(pathEl, startXpx, 'x', -1, centerX, centerY); if (startPt){ const ex = startPt.x - (0.2 * pxPerMm); const ey = startPt.y + (0.2 * pxPerMm); svgAppend($gDrills, svgEl('line', { x1:startPt.x, y1:startPt.y, x2:ex, y2:ey, stroke:HOLE_COLOR, 'stroke-width':2 })); const hx = ex - (0.5 * pxPerMm); const hy = ey + (2.5 * pxPerMm); svgAppend($gDrills, svgEl('circle', { cx:hx, cy:hy, r:(1.3/2) * pxPerMm, fill:'#fff', stroke:HOLE_COLOR, 'stroke-width':2 })); } } } // ---------- Drill toggle / mode ---------- // (Step 1–4 wired correctly, namespaced, no accidental nesting) $drillToggle.off('.clipon').on('click.clipon', function(){ const key = (activeSize||'_single'); const ts = traceBySize.get(key); if (!ts || ts.mode!=='edit') return; ts.drillOpen = !ts.drillOpen; $drillPanel.toggle(ts.drillOpen); $drillToggle.text(ts.drillOpen ? 'Edit Path' : 'Drill'); if (ts.drillOpen) { if (drillsVisible(ts)) { drawDrills(); svgShow($gDrills); } } else { svgClear($gDrills); svgHide($gDrills); } updateTraceVisibility(); }); $drLeftTop.add($drLeftBottom).add($drBridge).add($drFactorSpring).add($drFactorMag).off('.clipon').on('input.clipon change.clipon', function(){ const key = (activeSize||'_single'); const ts = traceBySize.get(key); if (!ts) return; // Bridge change in Magnetic → re-position TR & redraw path if ($(this).is($drBridge) && ts.magMode==='magnetic') { applyMagnetics(ts); drawTrace(key); // re-renders anchors/handles; persist inside drawTrace } // Factor Mag change in Magnetic → re-position TR & redraw path if ($(this).is($drFactorMag) && ts.magMode==='magnetic') { applyMagnetics(ts); drawTrace(key); // re-renders anchors/handles; persist inside drawTrace } // Factor Spring change in Spring mode → redraw drills if ($(this).is($drFactorSpring) && ts.magMode==='spring') { console.log('Spring factor changed to:', $drFactorSpring.val()); // Persist first to update drillData, then redraw persistDrill(); console.log('After persist, ts.drillData:', ts.drillData); if (drillsVisible(ts)) { drawDrills(); svgShow($gDrills); } return; // Exit early since we already called persistDrill } persistDrill(); if (drillsVisible(ts)) { drawDrills(); svgShow($gDrills); } else { svgHide($gDrills); } updateTraceVisibility(); }); $modeSwitch .off('.clipon') .on('click.clipon', function(){ const key = (activeSize||'_single'); const ts = traceBySize.get(key); if (!ts) return; const nowMag = !(ts.magMode==='magnetic'); setMode(ts, nowMag ? 'magnetic' : 'spring'); }) .on('keydown.clipon', function(e){ if (e.key==='Enter' || e.key===' ') { e.preventDefault(); $(this).trigger('click'); } }); function setMode(ts, mode){ ts.magMode = mode; const isMag = (mode==='magnetic'); $modeSwitch.toggleClass('is-on', isMag).attr('aria-checked', isMag ? 'true' : 'false'); $magOnly.toggle(isMag); $springOnly.toggle(!isMag); if (isMag) applyMagnetics(ts); else restoreSpring(ts); const key = (activeSize||'_single'); drawTrace(key); // redraw path + handles (+persistTrace inside) persistDrill(); if (drillsVisible(ts)) { drawDrills(); svgShow($gDrills); } else { svgHide($gDrills); } updateTraceVisibility(); } function applyMagnetics(ts){ var trIdx = findTRIndex(ts); if (!ts.springBackup) { ts.springBackup = { anchor: { ...ts.points[trIdx] }, hin: { ...ts.handles[trIdx].hin }, hout: { ...ts.handles[trIdx].hout } }; } // factorMag mm above bridge line, x at v1 const factorMag = (ts.drillData && ts.drillData.factorMag) ? ts.drillData.factorMag : 2.25; const yLine = $box.innerHeight()/2 - mmToPx(parseFloat($drBridge.val()) || 10); const newY = yLine - mmToPx(factorMag); const newX = v1X(); const old = ts.points[trIdx]; const dx = newX - old.x; const dy = newY - old.y; ts.points[trIdx] = { x:newX, y:newY }; ts.handles[trIdx].hin = { x: ts.handles[trIdx].hin.x + dx, y: ts.handles[trIdx].hin.y + dy }; const L = Math.hypot( ts.handles[trIdx].hout.x - old.x, ts.handles[trIdx].hout.y - old.y ); ts.handles[trIdx].hout = { x:newX, y:newY + L }; } function restoreSpring(ts){ if (!ts.springBackup) return; var trIdx = findTRIndex(ts); ts.points[trIdx] = { ...ts.springBackup.anchor }; ts.handles[trIdx] = { hin:{ ...ts.springBackup.hin }, hout:{ ...ts.springBackup.hout } }; ts.springBackup = null; } // ---------- Visibility helpers ---------- function updateTraceVisibility(){ const ts = traceBySize.get(activeSize||'_single'); if (!ts) return; const isTraceActive = $tabTrace.hasClass('is-active'); const hasPath = !!($path.attr('d')||'').trim(); const showHoles = drillsVisible(ts); const hideEdit = (ts.drillOpen && isTraceActive) || !isTraceActive; (hideEdit || !hasPath) ? svgHide($gAnch) : svgShow($gAnch); (hideEdit || !hasPath) ? svgHide($gCtrl) : svgShow($gCtrl); (hideEdit || !hasPath) ? svgHide($gLines): svgShow($gLines); // Only show/hide drills; drawDrills() is called elsewhere when needed showHoles ? svgShow($gDrills) : svgHide($gDrills); $path.toggle(hasPath); $download.toggle(hasPath && (!ts.mode || ts.mode==='edit')); $virtualBtn.toggle(hasPath && (!ts.mode || ts.mode==='edit')); $drillToggle.text(ts.drillOpen ? 'Edit Path' : 'Drill'); } function showIdleView(){ const ts = traceBySize.get(activeSize || '_single'); const hasPath = !!($path.attr('d')||'').trim(); if (hasPath) { $svg.css({ display:'block', pointerEvents:'none' }); svgHide($gAnch); svgHide($gCtrl); svgHide($gLines); if (ts && drillsVisible(ts)) { drawDrills(); svgShow($gDrills); } else { svgHide($gDrills); } $download.show(); } else { svgHide($gDrills); svgHide($gAnch); svgHide($gCtrl); svgHide($gLines); $svg.css('display','none'); $download.hide(); } } // ---------- Persist ---------- function persistTransform() { $hid.transform.val(JSON.stringify({ tx:state.tx, ty:state.ty, scale:state.scale, sx:state.sx, sy:state.sy, rot:state.rot })); } function persistFrame(){ $hid.frame.val(JSON.stringify({ a:parseFloat($frame.A.val())||0, b:parseFloat($frame.B.val())||0, bridge:parseFloat($frame.Br.val())||0, h:parseFloat($frame.H.val())||0, material:($frame.Mat.val()||'plastic'), dpi:getDpi(), color:$frame.Col.val()||'#008000' })); } function persistTrace(){ $hid.path.val($path.attr('d') || ''); const key = (activeSize||'_single'); const ts = traceBySize.get(key); $hid.points.val(JSON.stringify(ts ? (ts.points||[]) : [])); $hid.handles.val(JSON.stringify(ts ? (ts.handles||[]) : [])); // per-anchor {hin,hout} } function persistDrill(){ const key = (activeSize||'_single'); const ts = traceBySize.get(key); if (!ts) return; // Store drill data in the traceBySize object (size-specific) ts.drillData = { mode: ts.magMode, leftTopX: parseFloat($drLeftTop.val()) || 20, leftBottomX: parseFloat($drLeftBottom.val()) || 20, bridge: parseFloat($drBridge.val()) || 10, factorSpring: parseFloat($drFactorSpring.val()) || 41, factorMag: parseFloat($drFactorMag.val()) || 2.25 }; // Also persist to hidden field for database storage $hid.drill.val(JSON.stringify(ts.drillData)); } // Persist on post submit $('#post').on('submit', function(){ persistAll(); }); // ---------- Download Functions ---------- // Build and download OMA file function buildAndDownloadOMA($section, mode) { const key = (activeSize || '_single'); const ts = traceBySize.get(key); if (!ts) { alert('No trace data found for current size'); return; } const pathEl = document.getElementById('clipon-trace-path'); if(!pathEl){ alert('No trace path found. Click "Trace" and place/edit your path first.'); return; } const pathData = pathEl.getAttribute('d'); if (!pathData || !pathData.trim()) { alert('No trace path data found. Please create a trace first.'); return; } const bbox = pathEl.getBBox(); if (!bbox || !bbox.width || !bbox.height) { alert('Path bounds invalid. Adjust your path.'); return; } const centerX = bbox.x + bbox.width/2; const centerY = bbox.y + bbox.height/2; // Get DPI from the frame inputs const frameData = readFrameInputs($section); const dpi = frameData.dpi; const pxPerMm = pxPerMmFromDpi(dpi); const omaPerPx = 2540 / dpi; // Build radii array for TRCFMT const totalSamples = 4000; const radii = new Array(400).fill(0); const angleStep = 2*Math.PI/400; const totalLen = pathEl.getTotalLength(); for (let i = 0; i < totalSamples; i++) { const len = (totalLen * i) / totalSamples; const pt = pathEl.getPointAtLength(len); const x = (pt.x - centerX) * omaPerPx; const y = -(pt.y - centerY) * omaPerPx; const angle = (Math.atan2(y, x) + 2 * Math.PI) % (2 * Math.PI); const idx = Math.floor(angle / angleStep) % 400; const dist = Math.hypot(x, y); radii[idx] = Math.max(radii[idx], Math.round(dist)); } // Build OMA file content const lines = ['TRCFMT=1;400;E;R;F']; for (let i = 0; i < 400; i += 10) { lines.push('R=' + radii.slice(i, i + 10).join(';')); } lines.push('ZFMT=0'); // Add DRILLE records (drill holes) const leftTopX = parseFloat($section.find('.clipon-drill-left-top').val()) || 20; const leftBottomX = parseFloat($section.find('.clipon-drill-left-bottom').val()) || 20; const bridgeMm = parseFloat($section.find('.clipon-drill-bridge').val()) || 10; const material = frameData.material; const matFactor = materialFactorForSpring(material); // Left-side drill holes (top and bottom at independent X positions) const targetTopXpx = centerX - (leftTopX * pxPerMm); const targetBotXpx = centerX - (leftBottomX * pxPerMm); const topPt = samplePathForTargetXY(pathEl, targetTopXpx, 'x', -1, centerX, centerY); const botPt = samplePathForTargetXY(pathEl, targetBotXpx, 'x', +1, centerX, centerY); function normalSlotMM(sample){ if (!sample) return null; const n = inwardNormalUnitAt(pathEl, sample.s, centerX, centerY); const A = pxToMmPoint(sample.x, sample.y, centerX, centerY, dpi); const B = pxToMmPoint(sample.x + n.x*(1.0*pxPerMm), sample.y + n.y*(1.0*pxPerMm), centerX, centerY, dpi); return { A: {X:fmt1(A.X), Y:fmt1(A.Y)}, B: {X:fmt1(B.X), Y:fmt1(B.Y)} }; } if(topPt){ const {A,B} = normalSlotMM(topPt); // lines.push(`DRILLE=B;C;${A.X};${A.Y};1.0;${B.X};${B.Y};;2;`); lines.push(`DRILLE=B;C;${A.X};${A.Y};1.0;`); } if(botPt){ const {A,B} = normalSlotMM(botPt); // lines.push(`DRILLE=B;C;${A.X};${A.Y};1.0;${B.X};${B.Y};;2;`); lines.push(`DRILLE=B;C;${A.X};${A.Y};1.0;`); } if (mode === 'magnetic') { // Right slot: horizontal 2.0 mm to the left at Y = bridgeMm const targetYpx = centerY - (bridgeMm * pxPerMm); const rightPx = samplePathForTargetXY(pathEl, targetYpx, 'y', +1, centerX, centerY); if (rightPx){ const A = pxToMmPoint(rightPx.x, rightPx.y, centerX, centerY, dpi); const B = pxToMmPoint(rightPx.x - (2.0*pxPerMm), rightPx.y, centerX, centerY, dpi); const Af = {X:fmt1(A.X), Y:fmt1(A.Y)}; const Bf = {X:fmt1(B.X), Y:fmt1(B.Y)}; // enforce same Y (horizontal) Bf.Y = Af.Y; lines.push(`DRILLE=B;C;${Af.X};${Af.Y};1.0;${Bf.X};${Bf.Y};;2;`); } } else { // Spring: short slot + hole const factorSpring = (ts.drillData && ts.drillData.factorSpring) ? ts.drillData.factorSpring : 44; console.log('drawDrills spring mode - factorSpring:', factorSpring, 'from ts.drillData:', ts.drillData); const deltaMm = (factorSpring - (frameData.bridge - matFactor)) / 2; console.log('deltaMm calculated:', deltaMm, 'bridge:', frameData.bridge, 'matFactor:', matFactor); const rightEdgePx = bbox.x + bbox.width; const startXpx = rightEdgePx - (deltaMm * pxPerMm); const startPx = samplePathForTargetXY(pathEl, startXpx, 'x', -1, centerX, centerY); if(startPx){ // slot: 0.2 mm left & 0.2 mm down const A = pxToMmPoint(startPx.x, startPx.y, centerX, centerY, dpi); const B = pxToMmPoint(startPx.x - (0.2*pxPerMm), startPx.y + (0.2*pxPerMm), centerX, centerY, dpi); const Af = {X:fmt1(A.X), Y:fmt1(A.Y)}; const Bf = {X:fmt1(B.X), Y:fmt1(B.Y)}; lines.push(`DRILLE=B;C;${Af.X};${Af.Y};2;${Bf.X};${Bf.Y};;2;`); // hole: 0.6 mm left & 2.5 mm below end, dia 1.4 mm const HpxX = (startPx.x - (0.2*pxPerMm)) - (0.6*pxPerMm); const HpxY = (startPx.y + (0.2*pxPerMm)) + (2.5*pxPerMm); const H = pxToMmPoint(HpxX, HpxY, centerX, centerY, dpi); const Hf = {X:fmt1(H.X), Y:fmt1(H.Y)}; lines.push(`DRILLE=B;C;${Hf.X};${Hf.Y};${fmt1(1.4)};`); } } // Generate filename const brand = cliponGetBrandFromCategories(); const model = cliponGetModelFromAttributes(false); const fb = cliponGetEyeAndBridge(); const suffix = (mode === 'magnetic') ? 'M' : 'S'; const clean = s => (s || '') .toString() .replace(/×/g,'') .replace(/\(\d+\)\s*$/,'') .replace(/\s+/g,' ') .trim() .replace(/[\s_\-]+/g,'') .replace(/[^A-Za-z0-9]/g,''); const base = (clean(brand) + clean(model) + clean(fb.eyeA) + clean(fb.bridge)) || 'CliponTrace'; const filename = base.replace(/maketermprimary/gi, '') + suffix + '.oma'; // Create and download file const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 0); } // ---------- Download dropdown wiring ---------- // Grab the existing download UI from your PHP markup const $dlWrap = $root.find('.clipon-download'); const $dlMain = $dlWrap.find('.clipon-download-main'); const $dlMenu = $dlWrap.find('.clipon-download-menu'); const $dlMag = $dlWrap.find('.clipon-download-mag'); // Magnetic const $dlSpr = $dlWrap.find('.clipon-download-spr'); // Spring // Helper: do we have a path? function cliponHasPath() { const d = ($path.attr('d') || '').trim(); return !!d; } // Toggle menu on main button click $dlMain.off('click.clipon').on('click.clipon', function (e) { e.preventDefault(); e.stopPropagation(); if (!cliponHasPath()) { alert('No trace path found. Click "Trace" and place/edit your path first.'); return; } $dlMenu.toggle(); // simple show/hide }); // Close the menu when clicking outside $(document).off('click.cliponCloseMenu').on('click.cliponCloseMenu', function () { $dlMenu.hide(); }); // Keep clicks inside the menu from closing it immediately $dlMenu.off('click.stopClose').on('click.stopClose', function (e) { e.stopPropagation(); }); // Wire the two options to the working builders $dlMag.off('click.clipon').on('click.clipon', function (e) { e.preventDefault(); if (!cliponHasPath()) return; try { buildAndDownloadOMA($root, 'magnetic'); } finally { $dlMenu.hide(); } }); $dlSpr.off('click.clipon').on('click.clipon', function (e) { e.preventDefault(); if (!cliponHasPath()) return; try { buildAndDownloadOMA($root, 'spring'); } finally { $dlMenu.hide(); } }); // Optional: keyboard support $dlMain.attr('aria-haspopup', 'true').attr('aria-expanded', 'false'); $dlMain.on('click.cliponA11y', function(){ $dlMain.attr('aria-expanded', $dlMenu.is(':visible') ? 'true' : 'false'); }); $dlMain.on('keydown.cliponA11y', function(e){ if (e.key === 'ArrowDown' && $dlMenu.is(':hidden') && cliponHasPath()) { e.preventDefault(); $dlMenu.show(); $dlMag.trigger('focus'); } }); function setMaterialFromTags() { setTimeout(function() { try { var tagElements = document.querySelectorAll('.tagchecklist li'); console.log('setMaterialFromTags: Found', tagElements.length, 'tag elements'); if (tagElements.length === 0) { console.log('setMaterialFromTags: No tags found yet, retrying in 2 seconds...'); setTimeout(setMaterialFromTags, 2000); return; } var tags = []; for (var i = 0; i < tagElements.length; i++) { var rawText = tagElements[i].textContent; // Extract the actual tag name from "Remove term: TagName TagName" var match = rawText.match(/Remove term:\s*([\w-]+)/); if (match) { var tagText = match[1].toLowerCase().trim(); if (tagText && tags.indexOf(tagText) === -1) { tags.push(tagText); } } } console.log('setMaterialFromTags: Extracted tags:', tags); // Material options to match against var materialOptions = ['plastic', 'metal', 'semi-rimless']; var foundMaterial = null; // Check if any tag matches a material option for (var j = 0; j < materialOptions.length; j++) { if (tags.indexOf(materialOptions[j]) !== -1) { foundMaterial = materialOptions[j]; break; } } if (foundMaterial) { console.log('setMaterialFromTags: Found material:', foundMaterial); // Set the material dropdown $frame.Mat.val(foundMaterial); // Update all size maps with the selected material Object.keys(sizeMap).forEach(function(sizeKey) { if (sizeMap[sizeKey] && sizeMap[sizeKey].frame) { sizeMap[sizeKey].frame.material = foundMaterial; } }); // Persist the changes persistFrame(); $hid.sizesMap.val(JSON.stringify(sizeMap)); // Disable the dropdown $frame.Mat.prop('disabled', true).css('opacity', '0.6'); // Update guides updateGuides(); console.log('setMaterialFromTags: Material set and dropdown disabled'); } else { console.log('setMaterialFromTags: No material tag found in tags:', tags); } } catch(e) { console.error('setMaterialFromTags error:', e); } }, 3000); // Increased to 3 seconds } // ---------- Double section ---------- const $dblList = $('.clipon-double-list'); // Boot: Load saved double images from database const savedDoubleImages = readJSON($hid.doubleImgs.val(), []); savedDoubleImages.forEach(img => { const $B = $(`
`); $dblList.append($B); }); $('.clipon-upload-double').on('click', function(){ const frame = wp.media({ title:'Add Images', multiple:true, library:{type:'image'} }); frame.on('select', function(){ const out = []; frame.state().get('selection').each(a => { const j = a.toJSON(); out.push({ id:j.id, url:j.url }); const $B = $(`
`); $dblList.append($B); }); const curr = readJSON($hid.doubleImgs.val(), []); $hid.doubleImgs.val(JSON.stringify(curr.concat(out))); }); frame.open(); }); $dblList.on('click', '.clipon-reset[data-scope="double"]', function(){ const id = parseInt($(this).data('attachment-id'), 10); const list = readJSON($hid.doubleImgs.val(), []).filter(it => parseInt(it.id,10)!==id); $hid.doubleImgs.val(JSON.stringify(list)); $(this).closest('.clipon-double-box').remove(); }); // ---------- Utilities ---------- function readJSON(s, fb){ try{ return JSON.parse(s||''); } catch(_){ return fb; } } function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); } // ---------- Boot ---------- // FORCE ENABLE: Enable size tabs aggressively at multiple points function forceEnableSizeTabs() { if (!$sizeTabs || !$sizeTabs.length) return; $sizeTabs.each(function() { this.disabled = false; this.removeAttribute('disabled'); $(this).prop('disabled', false) .removeAttr('disabled') .css({ 'pointer-events': 'auto', 'cursor': 'pointer', 'opacity': '1' }); }); console.log('✓ Force enabled', $sizeTabs.length, 'size tabs'); } // Enable immediately if ($sizeTabs.length) { forceEnableSizeTabs(); } if (imageUploaded) { $wrap.show(); $img.show(); try { const t = readJSON($hid.transform.val(), null); if (t) Object.assign(state, t); applyTransform(); const f = readJSON($hid.frame.val(), null); if (f) { $frame.A.val(f.a||''); $frame.B.val(f.b||''); $frame.Br.val(f.bridge||''); $frame.H.val(f.h||''); $frame.Mat.val(f.material||'plastic'); $frame.DPI.val(f.dpi||350); $frame.Col.val(f.color||'#008000'); } const map = readJSON($hid.sizesMap.val(), {}); Object.keys(map||{}).forEach(k=>{ const r = map[k]||{}; const points = r.points||[]; const handles = normalizeControls(r.handles, points); const drill = r.drill || {}; if (points.length && handles) { traceBySize.set(k, { mode:'edit', points, handles, drillOpen:false, magMode: drill.mode || 'spring', springBackup:null, drillData: drill }); } }); if (activeSize) loadSize(activeSize); updateGuides(); showIdleView(); // Call material detection after everything is loaded setTimeout(setMaterialFromTags, 100); } catch(e){} } else { $wrap.hide(); // Initialize frame fields from active size tab when no image is uploaded if ($sizeTabs.length) { const $activeTab = $sizeTabs.filter('.is-active'); if ($activeTab.length) { const sizeLabel = $activeTab.data('size') || $activeTab.text().trim(); console.log('Active size label:', sizeLabel); // Debug log const ab = parseAB(sizeLabel); console.log('Parsed AB values:', ab); // Debug log if (ab && ab.a > 0) { $frame.A.val(ab.a); $frame.Br.val(ab.bridge); console.log('Set A:', ab.a, 'Bridge:', ab.bridge); // Debug log updateGuides(); } } } // Call material detection after DOM is ready setTimeout(setMaterialFromTags, 100); } if ($resetBtn.length) $resetBtn.on('click', fullReset); if ($resetTabBtn.length) $resetTabBtn.on('click', fullReset); // Re-enable size tabs after everything is initialized forceEnableSizeTabs(); setTimeout(forceEnableSizeTabs, 100); setTimeout(forceEnableSizeTabs, 500); // ---- size loader ---- function loadSize(label){ activeSize = label; const rec = sizeMap[label]; let use = rec; if (!use && imageUploaded && baseCalibrated && baseSizeOnUpload && label !== baseSizeOnUpload) { const base = sizeMap[baseSizeOnUpload]; const targetAB = parseAB(label); const baseAB = parseAB(baseSizeOnUpload); const baseA = baseAB.a || baseAOnUpload || 0; const targetA = targetAB.a || 0; if (base && baseA>0 && targetA>0) { const k = targetA / baseA; const t = Object.assign({}, base.transform || {}); t.scale = +( (t.scale||1) * k ).toFixed(6); // Scale the trace path if it exists let scaledPoints = []; let scaledHandles = []; let scaledPath = ''; if (base.points && base.points.length > 0) { // Get the center of the image wrapper for scaling reference const centerX = $wrap.width() / 2; const centerY = $wrap.height() / 2; // Scale points around the center scaledPoints = base.points.map(p => ({ x: centerX + (p.x - centerX) * k, y: centerY + (p.y - centerY) * k })); // Scale handles around the center if (base.handles && base.handles.length > 0) { scaledHandles = base.handles.map(h => ({ hin: { x: centerX + (h.hin.x - centerX) * k, y: centerY + (h.hin.y - centerY) * k }, hout: { x: centerX + (h.hout.x - centerX) * k, y: centerY + (h.hout.y - centerY) * k } })); } // Generate scaled path scaledPath = buildPathD(scaledPoints, scaledHandles); } use = { transform: t, frame: Object.assign({}, base.frame || {}, { a: targetA, bridge: targetAB.bridge }), path: scaledPath, points: scaledPoints, handles: scaledHandles, drill: base.drill || {} }; sizeMap[label] = use; } } if (use) { $hid.transform.val(JSON.stringify(use.transform||{})); $hid.frame.val(JSON.stringify(use.frame||{})); $hid.path.val(use.path||''); $hid.points.val(JSON.stringify(use.points||[])); $hid.handles.val(JSON.stringify(use.handles||[])); $hid.drill.val(JSON.stringify(use.drill||{})); Object.assign(state, use.transform||{}); applyTransform(); const f = use.frame||{}; $frame.A.val(f.a||''); $frame.B.val(f.b||''); $frame.Br.val(f.bridge||''); $frame.H.val(f.h||''); $frame.Mat.val(f.material||'plastic'); $frame.DPI.val(f.dpi||350); $frame.Col.val(f.color||'#008000'); updateGuides(); const key = (label||'_single'); const points = use.points||[]; const handles = normalizeControls(use.handles, points); const drill = use.drill || {}; // Load drill data into input fields $drLeftTop.val(drill.leftTopX || 20); $drLeftBottom.val(drill.leftBottomX || 20); $drBridge.val(drill.bridge || 10); $drFactorSpring.val(drill.factorSpring || 44); $drFactorMag.val(drill.factorMag || 2.25); // Load attributes for this size if (use.attributes) { applyAttributes(use.attributes); } if (points.length && handles) { const ts = { mode: 'edit', points, handles, drillOpen: false, magMode: drill.mode || 'spring', springBackup: null, drillData: drill }; traceBySize.set(key, ts); // Set the mode UI to match loaded drill data setMode(ts, ts.magMode); drawTrace(label); } else { clearPathAndMask(); } } else { const ab = parseAB(label); $frame.A.val(ab.a||''); $frame.Br.val(ab.bridge||''); $frame.DPI.val($frame.DPI.val()||350); resetTransform(); applyTransform(); updateGuides(); clearPathAndMask(); // Reset attributes $('.clipon-position-select').val(''); $('.clipon-bridge-select').val(''); } // Load size-specific image if available loadSizeImage(label); showIdleView(); } // Load size-specific image from uploaded media library URLs function loadSizeImage(sizeLabel) { const $sizeImagesInput = $hidden.find('input[name="_clipon_size_images"]'); const sizeImages = readJSON($sizeImagesInput.val(), {}); if (sizeImages[sizeLabel]) { const imageUrl = sizeImages[sizeLabel]; console.log('✓ Loading size-specific image for', sizeLabel); $img.attr('src', imageUrl); $hid.url.val(imageUrl); $wrap.show(); imageUploaded = true; } else { console.log('No size-specific image for', sizeLabel); } } function clearPathAndMask(){ $path.attr('d',''); svgClear($gAnch); svgClear($gCtrl); svgClear($gLines); hideMask(); } // ---------- Reset ---------- function fullReset(){ $img.attr('src','').hide(); $wrap.hide(); imageUploaded = false; baseCalibrated = false; baseSizeOnUpload = ''; baseAOnUpload = 0; resetTransform(); applyTransform(); $traceBtn.prop('disabled', true); clearPathAndMask(); svgClear($gDrills); $download.hide(); $hid.id.val(''); $hid.url.val(''); $hid.transform.val(''); $hid.frame.val(''); $hid.path.val(''); $hid.points.val(''); $hid.handles.val(''); $hid.drill.val(''); } // ---------- Page Load: Enable Trace tab if data exists ---------- (function initializeTraceTab() { // Check if we have an image (either from single_image_url or size_images) const hasSingleImage = !!$hid.url.val(); const $sizeImagesInput = $hidden.find('input[name="_clipon_size_images"]'); const sizeImages = readJSON($sizeImagesInput.val(), {}); const hasSizeImages = Object.keys(sizeImages).length > 0; const hasImage = hasSingleImage || hasSizeImages; // Check if we have path data const sizesMap = readJSON($hid.sizesMap.val(), {}); const hasPathData = Object.keys(sizesMap).length > 0 && Object.values(sizesMap).some(function(sizeData) { return sizeData && (sizeData.path || (sizeData.points && sizeData.points.length > 0)); }); // Enable Trace tab if both conditions are met if (hasImage && hasPathData) { $tabTrace.removeClass('disabled').prop('disabled', false); console.log('✓ Trace tab enabled on page load (image + path data found)'); } else { console.log('Trace tab disabled on page load:', { hasImage, hasPathData }); } })(); }); // Final failsafe: Force enable size tabs after everything loads jQuery(document).ready(function($) { setTimeout(function() { $('.clipon-size-tab').each(function() { this.disabled = false; this.removeAttribute('disabled'); $(this).prop('disabled', false) .removeAttr('disabled') .css({ 'pointer-events': 'auto', 'cursor': 'pointer' }); }); console.log('✓ Final failsafe: Size tabs forcibly enabled'); }, 1000); }); // On WordPress update, ensure attributes are saved jQuery(function($) { $('form#post').on('submit', function(e) { const $hidden = $('#clipon-hidden'); const $sizesMapInput = $hidden.find('input[name="_clipon_sizes_map"]'); const activeSize = $hidden.find('input[name="_clipon_active_size"]').val() || '_single'; // Parse current sizeMap let sizeMap; try { sizeMap = JSON.parse($sizesMapInput.val() || '{}') || {}; } catch(err) { sizeMap = {}; } // Get current attributes from UI const attrs = {}; $('.clipon-position-field').each(function() { const $field = $(this); const pos = $field.data('position'); if (!pos) return; const $select = $field.find('.clipon-position-select'); if ($select.length) { const val = $select.val(); if (val) attrs[pos] = val; } else { const $valueDiv = $field.find('.clipon-position-value'); if ($valueDiv.length) { const val = $valueDiv.text().trim(); if (val && val !== '—') attrs[pos] = val; } } }); const $bridgeField = $('.clipon-bridge-field'); const $bridgeSelect = $bridgeField.find('.clipon-bridge-select'); if ($bridgeSelect.length) { const val = $bridgeSelect.val(); if (val) attrs['magnetic-bridge'] = val; } else { const $bridgeValue = $bridgeField.find('.clipon-position-value'); if ($bridgeValue.length) { const val = $bridgeValue.text().trim(); if (val && val !== '—') attrs['magnetic-bridge'] = val; } } // Save attributes to the active size if (sizeMap[activeSize]) { sizeMap[activeSize].attributes = attrs; } else { // Create minimal entry for active size sizeMap[activeSize] = { attributes: attrs }; } // Save back to hidden input $sizesMapInput.val(JSON.stringify(sizeMap)); console.log('Pre-submit: Saved attributes for', activeSize, attrs); }); }); // end jQuery wrapper — form#post submit // Product Navigation Buttons jQuery(document).ready(function($) { if (typeof cliponNavData !== 'undefined') { var $buttons = $('
'); if (cliponNavData.prevUrl) { $buttons.append('
← Prev'); } if (cliponNavData.nextUrl) { $buttons.append('Next →'); } $('.wrap h1').first().css('position', 'relative').append($buttons); } }); // ── Admin Lightbox ── (Order pages) jQuery(function ($) { if (!$('#order_line_items, .woocommerce_order_items, #woocommerce-order-items').length) return; if (!$('.clipon-admin-lightbox-overlay').length) { $('body').append( '
' + '
' + '' + '' + 'View image' + '' + '
1 / 1
' + '
' + '
' ); } var images = []; var currentIndex = 0; function collectImages() { images = []; $('.woocommerce_order_items .clipon-view-image, #order_line_items .clipon-view-image').each(function () { images.push($(this).data('image')); }); } function showLightbox(imageUrl) { collectImages(); currentIndex = images.indexOf(imageUrl); if (currentIndex === -1) currentIndex = 0; updateLightboxImage(); $('.clipon-admin-lightbox-overlay').addClass('active'); } function updateLightboxImage() { if (!images.length) return; $('.clipon-admin-lightbox img').attr('src', images[currentIndex]); $('.clipon-lightbox-counter .current').text(currentIndex + 1); $('.clipon-lightbox-counter .total').text(images.length); $('.clipon-lightbox-prev, .clipon-lightbox-next').toggle(images.length > 1); } $('.clipon-lightbox-prev').on('click', function (e) { e.stopPropagation(); currentIndex = (currentIndex - 1 + images.length) % images.length; updateLightboxImage(); }); $('.clipon-lightbox-next').on('click', function (e) { e.stopPropagation(); currentIndex = (currentIndex + 1) % images.length; updateLightboxImage(); }); $('.clipon-lightbox-close, .clipon-admin-lightbox-overlay').on('click', function (e) { if (e.target === this) $('.clipon-admin-lightbox-overlay').removeClass('active'); }); $(document).on('keydown', function (e) { if (!$('.clipon-admin-lightbox-overlay').hasClass('active')) return; if (e.key === 'ArrowLeft') $('.clipon-lightbox-prev').click(); if (e.key === 'ArrowRight') $('.clipon-lightbox-next').click(); if (e.key === 'Escape') $('.clipon-admin-lightbox-overlay').removeClass('active'); }); $(document).on('click', '.clipon-view-image', function (e) { e.preventDefault(); showLightbox($(this).data('image')); }); }); // ── Create Product Wizard ── (Order pages) jQuery(function ($) { if (!$('#order_line_items, .woocommerce_order_items, #woocommerce-order-items').length) return; $('#order_line_items tr.item, .woocommerce_order_items tbody tr.item').each(function () { var $row = $(this); var productId = ''; var $productLink = $row.find('.wc-order-item-name a'); if ($productLink.length) { var href = $productLink.attr('href') || ''; var match = href.match(/post=(\d+)/); if (match) productId = match[1]; } if (!productId) { var sku = ($row.find('.wc-order-item-sku').text() || '').replace('SKU:', '').trim(); if (sku.indexOf('-') > -1) productId = sku.split('-')[0]; } if (productId != '1120') return; var brand = '', model = '', sizes = '', lensHeight = '', lensPhoto = '', topPhoto = '', bottomPhoto = ''; $row.find('.display_meta tbody tr, .wc-order-item-meta tbody tr').each(function () { var label = $(this).find('th').text().replace(':', '').trim(); var $td = $(this).find('td'); var value = $td.find('p').text().trim() || $td.text().trim(); if (label === 'Brand') brand = value; if (label === 'Model') model = value; if (label === 'Sizes') sizes = value; if (label === 'Lens Height') lensHeight = value; var $link = $td.find('.clipon-view-image'); if (label === 'Lens') lensPhoto = $link.data('image') || ''; if (label === 'Top') topPhoto = $link.data('image') || ''; if (label === 'Bottom') bottomPhoto = $link.data('image') || ''; }); if (!brand || !model) return; $.ajax({ url: ajaxurl, type: 'POST', data: { action: 'check_clipon_product_exists', brand: brand, model: model }, success: function (response) { if (response.success && !response.data.exists) { var searchTerm = brand + ' ' + model; var googleUrl = 'https://www.google.com/search?q=' + encodeURIComponent(searchTerm); var ebayUrl = 'https://www.ebay.com/sch/i.html?_nkw=' + encodeURIComponent(searchTerm); var $metaView = $row.find('.display_meta, .wc-order-item-meta, td.item_cost').first(); var buttonHtml = '
' + '' + '' + '' + searchTerm + '' + '' + 'ebay' + ' ' + searchTerm + '' + '' + '
'; $metaView.append(buttonHtml); } } }); }); $(document).on('click', '.clipon-create-product-btn', function () { var $btn = $(this); if ($btn.hasClass('product-created')) { var url = $btn.data('edit-url'); if (url) window.open(url, '_blank'); return; } var brand = $btn.data('brand'); var model = $btn.data('model'); var sizes = $btn.data('sizes'); var lensHeight = $btn.data('lens-height'); var lensPhoto = $btn.data('lens-photo'); var topPhoto = $btn.data('top-photo'); var bottomPhoto = $btn.data('bottom-photo'); var popupHtml = '
' + '
' + '
' + '

Create Clip-On Product

' + '
' + '
1
Frame Type
' + '
' + '
2
Frame Info
' + '
' + '
3
Clip-on Parts
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '' + '' + '
' + '
' + '' + '' + '
' + '' + '
' + '
'; $('body').append(popupHtml); var currentStep = 1; function ccpUpdateUI() { $('.ccp-step-panel').hide(); $('.ccp-step-panel[data-panel="' + currentStep + '"]').show(); $('.ccp-step').each(function () { var s = +$(this).data('step'); $(this).removeClass('active done'); if (s === currentStep) $(this).addClass('active'); if (s < currentStep) $(this).addClass('done'); }); $('.ccp-step-line').each(function (i) { $(this).toggleClass('done', i < currentStep - 1); }); $('#ccp-step-label').text(currentStep); $('#ccp-back').text(currentStep === 1 ? 'Cancel' : '\u2190 Back'); $('#ccp-next').text(currentStep === 3 ? 'Create Product' : 'Next \u2192'); } $(document).on('click', '.ccp-frame-type-btn', function () { $('.ccp-frame-type-btn').removeClass('selected'); $(this).addClass('selected'); }); // Sync mode: 'all' | 'pairs' | 'different' (default) var syncMode = 'different'; $(document).on('click', '#ccp-sync-all, #ccp-sync-pairs, #ccp-sync-diff', function () { $('.ccp-sync-btn').removeClass('is-active'); $(this).addClass('is-active'); var id = $(this).attr('id'); if (id === 'ccp-sync-all') syncMode = 'all'; else if (id === 'ccp-sync-pairs') syncMode = 'pairs'; else syncMode = 'different'; }); // Live field syncing based on active mode $(document).on('input', '#popup-top-left, #popup-top-right, #popup-bottom-left, #popup-bottom-right', function () { var val = $(this).val(); var id = $(this).attr('id'); if (syncMode === 'all') { $('#popup-top-left, #popup-top-right, #popup-bottom-left, #popup-bottom-right').not(this).val(val); } else if (syncMode === 'pairs') { if (id === 'popup-top-left' || id === 'popup-top-right') { $('#popup-top-left, #popup-top-right').not(this).val(val); } else { $('#popup-bottom-left, #popup-bottom-right').not(this).val(val); } } // 'different': no action }); $('#ccp-back').on('click', function () { if (currentStep === 1) { $('.clipon-create-popup-overlay').remove(); } else { currentStep--; ccpUpdateUI(); } }); $('.clipon-create-popup-overlay').on('click', function (e) { if (e.target === this) $('.clipon-create-popup-overlay').remove(); }); $('#ccp-next').on('click', function () { if (currentStep === 1) { if (!$('.ccp-frame-type-btn.selected').length) { alert('Please select a frame type.'); return; } currentStep = 2; ccpUpdateUI(); } else if (currentStep === 2) { if (!$('#popup-brand').val() || !$('#popup-model').val()) { alert('Brand and Model are required.'); return; } currentStep = 3; ccpUpdateUI(); } else if (currentStep === 3) { var data = { action: 'create_clipon_product', brand: $('#popup-brand').val(), model: $('#popup-model').val(), sizes: $('#popup-sizes').val(), lens_height: $('#popup-lens-height').val(), lens_photo: $('#popup-lens-photo').val(), top_photo: $('#popup-top-photo').val(), bottom_photo: $('#popup-bottom-photo').val(), top_left: $('#popup-top-left').val(), top_right: $('#popup-top-right').val(), bottom_left: $('#popup-bottom-left').val(), bottom_right: $('#popup-bottom-right').val(), magnetic_bridge: $('#popup-magnetic-bridge').val(), material_type: $('.ccp-frame-type-btn.selected').data('material') || '' }; $('#ccp-next').prop('disabled', true).text('Creating...'); $.ajax({ url: ajaxurl, type: 'POST', data: data, success: function (response) { if (response.success) { $('.clipon-create-popup-overlay').remove(); $btn.text('Created! View Product').css('background', '#46b450').addClass('product-created').data('edit-url', response.data.edit_url); } else { alert('Error: ' + response.data.message); $('#ccp-next').prop('disabled', false).text('Create Product'); } }, error: function () { alert('Error creating product'); $('#ccp-next').prop('disabled', false).text('Create Product'); } }); } }); }); }); // ── Addon Toggle + Image Upload ── (Product data panels) jQuery(function ($) { if (!$('#custom_addon_form_data').length) return; // Toggle wrapper click $('.addon-toggle-wrapper').on('click', function (e) { if (e.target.tagName !== 'INPUT') { var $cb = $(this).find('input[type="checkbox"]'); $cb.prop('checked', !$cb.is(':checked')); } }); // WP Media uploader for placeholder images $(document).on('click', '.addon-upload-image', function (e) { e.preventDefault(); var $button = $(this); var $field = $button.closest('.addon-image-field'); var $input = $field.find('.addon-image-url'); var $preview = $field.find('.addon-image-preview'); var mediaUploader = wp.media({ title: 'Select Placeholder Image', button: { text: 'Use this image' }, multiple: false }); mediaUploader.on('select', function () { var attachment = mediaUploader.state().get('selection').first().toJSON(); $input.val(attachment.url); $preview.html(''); if (!$field.find('.addon-remove-image').length) { $button.after(''); } }); mediaUploader.open(); }); $(document).on('click', '.addon-remove-image', function (e) { e.preventDefault(); var $field = $(this).closest('.addon-image-field'); $field.find('.addon-image-url').val(''); $field.find('.addon-image-preview').html('No image set'); $(this).remove(); }); }); // ── Category Prefix Field Mover ── (product_cat taxonomy pages) jQuery(function ($) { if (!window.location.href.includes('taxonomy=product_cat')) return; var $prefixRow = $('input#category_prefix').closest('tr, .form-field'); var $nameRow = $('.form-field.term-name-wrap').closest('tr, .form-field'); if ($prefixRow.length && $nameRow.length) { $prefixRow.insertAfter($nameRow); } }); // ── Bulk Discounts Table ── (WooCommerce settings page) jQuery(function ($) { if (!$('#bulk-discount-table').length) return; $('#btn-add-rule').on('click', function () { var row = '' + '' + ' %' + '' + ''; $('#bulk-discount-tbody').append(row); }); $(document).on('click', '.btn-remove-rule', function () { if ($('.bulk-discount-row').length > 1) { $(this).closest('tr').remove(); } }); }); 3DClips™ - Custom Clip On Sunglasses For Warby Parker Winston

MADE IN THE U.S.A | SHIPPING WORLDWIDE | INFO@CLIPONEXPRESS.COM

* The Final Product Will Match The Shape Of Your Eyeglasses.

3DClips™ – Custom Clip On Sunglasses For Warby Parker Winston

Price range: $39.00 through $59.00

1. Select Bridge And Lens Color

3DClips™ - Custom Clip On Sunglasses For Warby Parker Winston 1

Each pair of clip on sunglasses is made to order, customized to fit your frame. Instantly turn your spectacles into prescription sunglasses.

✔ Compatibly: All shapes, eyeglasses type (plastic, metal, semi rimless and rimless) and size (eye size 30-61 mm)
✔ Lightweight: Weighs about 6 grams. Won't drag your glasses
✔ UV400: Full protection from harmful UVA and UVB rays
✔ Polarized: Reduces unwanted glare reflecting off roads, car parts, water and snow
✔ Lifetime Warranty: Covers bad workmanship and mechanical failure for as long as you keep the clip on
✔ Shipping: Worldwide via USPS

Reviews

There are no reviews yet.

Only logged in customers who have purchased this product may leave a review.

0
YOUR CART
  • No products in the cart.