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(); } }); }); How to order custom clip on sunglasses using your mobile device

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

Ordering custom clip on sunglasses used to mean you’ll have to go to a physical location or mail your frame for the manufacturing process. Modern technologies like the internet and smart mobile devices now allow the luxury of ordering from almost anywhere.

In this short guide we’ll go over the the necessary information you’ll need to provide in order to get a pair of clip on sunglasses that fits your frame perfectly:

 

  • How to take pictures of your spectacles
  • Understanding the frame information
  • How to measure your spectacles 

How to take pictures of your spectacles

Instead of using a physical object as a mold, we’ll use images of the frame to create a pattern for the clip on lenses, set it’s base curve, determine the length of the prongs and take unusual features (for example, a protruding nose bridge) into consideration.

A total of 3 images are needed: 

1. Lens

This image is used to create a pattern for the clip on lenses. We trace the edges of the frame and/or lenses thus creating a shape that will offer sufficient coverage.

For optimal results, the picture has to be taken in a perpendicular angle to the plane of the lens and from a distance to prevent a fisheye effect which will distort the shape.

Guideline:

– Fold the temples and place on a flat surface

– Turn flash off

– Aim at the center of the lens from about a foot away and zoom in

– Make sure the angle is perpendicular to the plane of the lens

The lens image is used to create a pattern for the clip on sunglasses lenses

2. Top

This image will be used to determine the base curve of the clip on and the length of the clip on’s top prongs.

If there are any unusual features that should be taken into consideration such as a protruding bridge or thick lenses, they will show up in this image. 

The image of the top will be used to set the base curve of the clip on sunglasses

3. Bottom

This image will be used to determine the base curve of the clip on and the length of the clip on’s bottom prongs.

If there are any unusual features that should be taken into consideration such as a protruding bridge or thick lenses, they will show up in this image.

The image of the bottom will be used to set the length of the bottom prongs for the clip on sunglasses

Understanding frame Information

Most frame manufactures print or curve information on the frame that usually includes the following: The brand name, Model name or number, color, eye size, bridge size and temple length.

We use this information to categorize and properly scale the clip-on so it fits the frame properly.

Brand

The brand name will usually be printed on one of the temples.

Some of the most popular brands people buy clip on sunglasses for are Warby Parker, Ray-Ban, Nike and Oakley.

The brand model of the frame in the example is Valentino (it’s printed on the other temple).

Model

The Model is either a number, a name or a combination of letters and numbers.

Warby Parker uses different names, Ray-Ban models are RB or RX followed by a 4 digit number and Nike uses 3 or 4 digit numbers as the model.

The color is also printed on the frame, however it’s not relevant to the order. The color code comes after the model.

In the example the model is V2664 and the color code is 031.

Sizes

  • Eye Size
  • Bridge Size
  • Temples Length

The Eye size is the width of the prescription lens in millimeters. The bridge size is the distance between the lenses. The temple length isn’t relevant to the order.

The eye and bridge sizes are usually separated with a square.

In the example, it’s 5116 (135 is the temple length). 

How to order custom clip on sunglasses using your mobile device 1

How to measure your spectacles

The printed sizes are accurate in most cases, however there are unexpected exceptions. In order to assure the accuracy of the scale, you may be asked to provide a real world measurement.  

Use a caliper (or a ruler) to measure the total height of the spectacles.

The preferable measuring unit is millimeters.

 

How to measure your eyeglasses when ordering custom clip on sunglasses

Ready To Place An Order?

0
YOUR CART
  • No products in the cart.