diff --git a/src/components/Navbar/QuickButton/QuickButton.vue b/src/components/Navbar/QuickButton/QuickButton.vue index 51e176f5a..a7e62ff40 100644 --- a/src/components/Navbar/QuickButton/QuickButton.vue +++ b/src/components/Navbar/QuickButton/QuickButton.vue @@ -70,25 +70,17 @@ -
- - - - + +
+ +
@@ -154,14 +146,79 @@ const simulatorMobileStore = useSimulatorMobileStore() color: white; } -.zoom-slider { +/* Zoom controls with slider (1-100% display) */ +.zoom-controls { display: flex; gap: 1rem; - width: 90%; + justify-content: center; + align-items: center; + padding: 0.5rem 0; +} + +.zoom-button-decrement, +.zoom-button-increment { + background: transparent; + color: white; + border: none; + cursor: pointer; + font-size: 24px; + font-weight: bold; + padding: 0 8px; + line-height: 1; + min-width: 32px; +} + +.zoom-button-decrement:hover, +.zoom-button-increment:hover { + opacity: 0.7; +} + +.zoom-slider { + width: 120px; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.zoom-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: white; + border-radius: 50%; + cursor: pointer; + transition: background 0.15s ease-in-out; } -.custom-range { - width: 90% !important; +.zoom-slider::-webkit-slider-thumb:hover { + background: #ddd; +} + +.zoom-slider::-moz-range-thumb { + width: 16px; + height: 16px; + background: white; + border: none; + border-radius: 50%; + cursor: pointer; + transition: background 0.15s ease-in-out; +} + +.zoom-slider::-moz-range-thumb:hover { + background: #ddd; +} + +.zoom-label { + color: white; + font-size: 14px; + font-weight: 500; + min-width: 45px; + text-align: right; } @media (max-width: 768px) { diff --git a/src/simulator/src/embedListeners.js b/src/simulator/src/embedListeners.js index 6f7aceced..aee6ac008 100644 --- a/src/simulator/src/embedListeners.js +++ b/src/simulator/src/embedListeners.js @@ -174,16 +174,19 @@ export default function startListeners() { updateSimulationSet(true); updatePositionSet(true); - // zoom in (+) + // Zoom is now on Cmd/Ctrl +/- (primary zoom method) if (e.key == 'Meta' || e.key == 'Control') { simulationArea.controlDown = true; } - if (simulationArea.controlDown && (e.keyCode == 187 || e.KeyCode == 171)) { + // Zoom in: Cmd/Ctrl + '+' or Cmd/Ctrl + '=' + // (On US keyboards, '+' is Shift+'=', so we accept both to handle with/without Shift) + if (simulationArea.controlDown && (e.keyCode == 187 || e.keyCode == 171 || e.key == '+' || e.key == '=')) { e.preventDefault(); ZoomIn(); } - // zoom out (-) - if (simulationArea.controlDown && (e.keyCode == 189 || e.Keycode == 173)) { + // Zoom out: Cmd/Ctrl + '-' + // (Only '-', not '_', since '_' requires Shift and could cause confusion) + if (simulationArea.controlDown && (e.keyCode == 189 || e.keyCode == 173 || e.key == '-')) { e.preventDefault(); ZoomOut(); } diff --git a/src/simulator/src/engine.js b/src/simulator/src/engine.js index 5227eb764..b200299ec 100644 --- a/src/simulator/src/engine.js +++ b/src/simulator/src/engine.js @@ -322,7 +322,7 @@ export function updateSelectionsAndPane(scope = globalScope) { simulationArea.lastSelected === scope.root && simulationArea.mouseDown ) { - // pane canvas to give an idea of grid moving + // Original behavior: pan canvas when dragging on empty space (not selecting objects) if (!objectSelection) { globalScope.ox = simulationArea.mouseRawX - diff --git a/src/simulator/src/listeners.js b/src/simulator/src/listeners.js index 2727668a1..420c7a297 100644 --- a/src/simulator/src/listeners.js +++ b/src/simulator/src/listeners.js @@ -7,6 +7,7 @@ /* eslint-disable prefer-template */ /* eslint-disable no-param-reassign */ // Most Listeners are stored here +const DPR = window.devicePixelRatio || 1 import { layoutModeGet, tempBuffer, @@ -28,7 +29,7 @@ import { changeScale, findDimensions } from './canvasApi' import { scheduleBackup } from './data/backupCircuit' import { hideProperties, deleteSelected, uxvar, exitFullView } from './ux'; import { updateRestrictedElementsList, updateRestrictedElementsInScope, hideRestricted, showRestricted } from './restrictedElementDiv'; -import { removeMiniMap, updatelastMinimapShown } from './minimap' +import miniMapArea, { removeMiniMap, updatelastMinimapShown } from './minimap' import undo from './data/undo' import redo from './data/redo' import { copy, paste, selectAll } from './events' @@ -419,19 +420,22 @@ export default function startListeners() { simulationArea.controlDown = true } - // zoom in (+) + // Keyboard zoom shortcuts + // Zoom in: Cmd/Ctrl + '+' or numpad '+' + // (On US keyboards, '+' is Shift+'='; we only check for '+' to avoid + // triggering on plain '=' which would require Cmd/Ctrl+Shift+= instead) if ( (simulationArea.controlDown && - (e.keyCode == 187 || e.keyCode == 171)) || + (e.keyCode == 187 || e.keyCode == 171 || e.key == '+')) || e.keyCode == 107 ) { e.preventDefault() ZoomIn() } - // zoom out (-) + // Zoom out: Cmd/Ctrl + '-' or numpad '-' if ( (simulationArea.controlDown && - (e.keyCode == 189 || e.keyCode == 173)) || + (e.keyCode == 189 || e.keyCode == 173 || e.key == '-')) || e.keyCode == 109 ) { e.preventDefault() @@ -570,27 +574,114 @@ export default function startListeners() { onDoubleClickorTap(e); }); - function MouseScroll(event) { - updateCanvasSet(true) - event.preventDefault() - var deltaY = event.wheelDelta ? event.wheelDelta : -event.detail + /** + * Handle mouse wheel / trackpad scroll events + * CHANGED: Now pans the canvas instead of zooming + * Zoom is controlled ONLY by: + * - UI buttons/sliders (QuickButton components) + * - Keyboard shortcuts (Cmd/Ctrl +/-) + * + * This prevents accidental zoom when scrolling/swiping on trackpad + */ + function handleCanvasPan(event) { event.preventDefault() - var deltaY = event.wheelDelta ? event.wheelDelta : -event.detail - const direction = deltaY > 0 ? 1 : -1 - handleZoom(direction) - updateCanvasSet(true) + + // Extract scroll delta from various browser event formats + const scrollDelta = extractScrollDelta(event) + + // Apply panning by adjusting viewport origin + applyCanvasPan(scrollDelta.x, scrollDelta.y) + + // Update display and minimap + updateAfterPan() + } + + /** + * Extract scroll delta from browser wheel event + * Handles multiple browser API formats + */ + function extractScrollDelta(event) { + let deltaX = 0 + let deltaY = 0 + + if (event.deltaX !== undefined && event.deltaY !== undefined) { + // Modern browsers: event.deltaX, event.deltaY + // Normalize by deltaMode: 0 = pixel, 1 = line, 2 = page + let modeScale = 1 + + if (event.deltaMode === 1) { + // Lines → approximate pixels + modeScale = 16 + } else if (event.deltaMode === 2) { + // Pages → approximate one viewport height + modeScale = window.innerHeight + } + + deltaX = event.deltaX * modeScale + deltaY = event.deltaY * modeScale + } else if (event.wheelDeltaX !== undefined && event.wheelDeltaY !== undefined) { + // Webkit browsers: wheelDeltaX, wheelDeltaY (inverted sign) + deltaX = -event.wheelDeltaX + deltaY = -event.wheelDeltaY + } else if (event.wheelDelta !== undefined) { + // Legacy browsers: wheelDelta (vertical only, inverted sign) + deltaY = -event.wheelDelta + } else if (event.detail !== undefined) { + // Firefox legacy API: detail (vertical only) + deltaY = event.detail * 40 // Scale to approximate pixels + } + + return { x: deltaX, y: deltaY } + } + + /** + * Apply panning to the canvas viewport + * @param {number} deltaX - Horizontal pan delta (from scroll event) + * @param {number} deltaY - Vertical pan delta (from scroll event) + * + * The canvas origin (ox, oy) is the pixel offset of the canvas content. + * To make content follow the finger/wheel naturally (natural scroll): + * - Swipe right → deltaX > 0 → content moves right → ox decreases + * - Swipe left → deltaX < 0 → content moves left → ox increases + * - Swipe down → deltaY > 0 → content moves down → oy decreases + * - Swipe up → deltaY < 0 → content moves up → oy increases + * So we subtract the delta from the origin. + */ + function applyCanvasPan(deltaX, deltaY) { + globalScope.ox -= deltaX + globalScope.oy -= deltaY + + // Round to avoid subpixel rendering issues + globalScope.ox = Math.round(globalScope.ox) + globalScope.oy = Math.round(globalScope.oy) + } + + /** + * Update canvas and minimap after panning + */ + function updateAfterPan() { gridUpdateSet(true) - - if (layoutModeGet()) layoutUpdate() - else update() // Schedule update not working, this is INEFFICIENT + updateCanvasSet(true) + scheduleUpdate() + + // Show minimap temporarily (if enabled) + if (!embed && !lightMode) { + findDimensions(globalScope) + miniMapArea.setup() + const miniMapElement = document.querySelector('#miniMap') + if (miniMapElement) { + miniMapElement.style.display = 'block' + updatelastMinimapShown() + setTimeout(removeMiniMap, 2000) + } + } } - document - .getElementById('simulationArea') - .addEventListener('mousewheel', MouseScroll) - document - .getElementById('simulationArea') - .addEventListener('DOMMouseScroll', MouseScroll) + // Register wheel/trackpad event listeners for canvas panning + const simulationAreaElement = document.getElementById('simulationArea') + simulationAreaElement.addEventListener('wheel', handleCanvasPan, { passive: false }) + simulationAreaElement.addEventListener('mousewheel', handleCanvasPan, { passive: false }) + simulationAreaElement.addEventListener('DOMMouseScroll', handleCanvasPan, { passive: false }) document.addEventListener('cut', (e) => { if (verilogModeGet()) return @@ -706,7 +797,8 @@ export default function startListeners() { }) }) - zoomSliderListeners() + // zoomSliderListeners() (jQuery-based) disabled here - zoom slider handling has moved to Vue + // Vue components (QuickButton.vue / QuickButtonMobile.vue) now manage the zoom slider and +/- buttons if (!embed) { setupTimingListeners() } @@ -724,156 +816,145 @@ function resizeTabs() { window.addEventListener('resize', resizeTabs) resizeTabs() -// direction is only 1 or -1 -function handleZoom(direction) { - if (globalScope.scale > 0.5 * DPR) { - changeScale(direction * 0.1 * DPR); - } else if (globalScope.scale < 4 * DPR) { - changeScale(direction * 0.1 * DPR); +/** + * Apply zoom change in the specified direction + * @param {number} direction - Direction and magnitude of zoom change (1 = zoom in, -1 = zoom out) + */ +const MIN_ZOOM_SCALE = 0.5 +const MAX_ZOOM_SCALE = 4 * DPR + +function applyZoomChange(direction) { + const zoomDelta = direction * 0.1 * DPR + const targetScale = Math.max( + MIN_ZOOM_SCALE, + Math.min(MAX_ZOOM_SCALE, globalScope.scale + zoomDelta) + ) + + if (targetScale !== globalScope.scale) { + changeScale(targetScale - globalScope.scale) + gridUpdateSet(true) + scheduleUpdate() } - gridUpdateSet(true); - scheduleUpdate(); } + + +/** + * Zoom in - increases the canvas scale + * Can be called from keyboard shortcuts or UI buttons + */ export function ZoomIn() { - handleZoom(1); + applyZoomChange(1) } + +/** + * Zoom out - decreases the canvas scale + * Can be called from keyboard shortcuts or UI buttons + */ export function ZoomOut() { - handleZoom(-1); + applyZoomChange(-1) } -function zoomSliderListeners() { - document.getElementById("customRange1").value = 5; - document.getElementById('simulationArea').addEventListener('DOMMouseScroll', zoomSliderScroll); - document.getElementById('simulationArea').addEventListener('mousewheel', zoomSliderScroll); - let curLevel = document.getElementById("customRange1").value; - $(document).on('input change', '#customRange1', function (e) { - const newValue = $(this).val(); - const changeInScale = newValue - curLevel; - updateCanvasSet(true); - changeScale(changeInScale * 0.1, 'zoomButton', 'zoomButton', 3) - gridUpdateSet(true); - curLevel = newValue; - }); - function zoomSliderScroll(e) { - let zoomLevel = document.getElementById("customRange1").value; - const deltaY = e.wheelDelta ? e.wheelDelta : -e.detail; - const directionY = deltaY > 0 ? 1 : -1; - if (directionY > 0) zoomLevel++ - else zoomLevel-- - if (zoomLevel >= 45) { - zoomLevel = 45; - document.getElementById("customRange1").value = 45; - } else if (zoomLevel <= 0) { - zoomLevel = 0; - document.getElementById("customRange1").value = 0; - } else { - document.getElementById("customRange1").value = zoomLevel; - curLevel = zoomLevel; - } - } - function sliderZoomButton(direction) { - const zoomSlider = $('#customRange1'); - let currentSliderValue = parseInt(zoomSlider.val(), 10); - if (direction === -1) { - currentSliderValue--; - } else { - currentSliderValue++; - } - zoomSlider.val(currentSliderValue).change(); - } - $('#decrement').click(() => { - sliderZoomButton(-1); - }); - $('#increment').click(() => { - sliderZoomButton(1); - }); -} - -// Desktop App Listeners - -listen('new-project', () => { - logixFunction.newProject(); -}); - -listen('save-online', () => { - logixFunction.save(); -}); - -listen('save-offline', () => { - logixFunction.saveOffline(); -}); -listen('open-offline', () => { - logixFunction.createOpenLocalPrompt(); -}); - -listen('export', () => { - logixFunction.ExportProject(); -}); - -listen('import', () => { - logixFunction.ImportProject(); -}); - -listen('recover', () => { - logixFunction.recoverProject(); -}); - -listen('clear', () => { - logixFunction.clearProject(); -}); - -listen('preview-circuit', () => { - logixFunction.fullViewOption(); -}); - -listen('new-circuit', () => { - logixFunction.createNewCircuitScope(); -}); - -listen('new-verilog-module', () => { - logixFunction.newVerilogModule(); -}); +/** + * Set zoom level from a slider value + * Converts a slider value (typically 0-200) to the internal zoom scale (0.5-4.0 * DPR) + * and applies it centered on the viewport. + * + * @param {number} sliderValue - The value from a zoom slider + * @param {number} minSliderValue - Minimum slider value (default: 0) + * @param {number} maxSliderValue - Maximum slider value (default: 10) + * @param {number} minZoom - Minimum zoom scale (default: 0.5 * DPR) + * @param {number} maxZoom - Maximum zoom scale (default: 4 * DPR) + * + * Example: setZoomFromSlider(100, 0, 200) // Sets zoom to middle of range + */ +export function setZoomFromSlider( + sliderValue, + minSliderValue = 0, + maxSliderValue = 10, + minZoom = 0.5 * DPR, + maxZoom = 4 * DPR +) { + // Guard against invalid inputs and degenerate ranges + const inputs = [sliderValue, minSliderValue, maxSliderValue, minZoom, maxZoom] + if ( + !inputs.every(Number.isFinite) || + maxSliderValue === minSliderValue || + maxZoom === minZoom + ) { + return + } -listen('insert-sub-circuit', () => { - logixFunction.createSubCircuitPrompt(); -}); + // Normalize slider value to 0-1 range + const normalizedValue = + (sliderValue - minSliderValue) / (maxSliderValue - minSliderValue) -listen('combinational-analysis', () => { - logixFunction.createCombinationalAnalysisPrompt(); -}); + // Map to zoom scale range + const targetScale = minZoom + normalizedValue * (maxZoom - minZoom) -listen('hex-bin-dec', () => { - logixFunction.bitconverter(); -}); + // Clamp to valid zoom range + const clampedScale = Math.max(minZoom, Math.min(maxZoom, targetScale)) -listen('download-image', () => { - logixFunction.createSaveAsImgPrompt(); -}); + // Calculate delta from current scale + const scaleDelta = clampedScale - globalScope.scale -listen('themes', () => { - logixFunction.colorThemes(); -}); + // Avoid applying invalid or zero delta + if (!Number.isFinite(scaleDelta) || scaleDelta === 0) return -listen('custom-shortcut', () => { - logixFunction.customShortcut(); -}); + // Apply zoom centered on viewport (method = 3) + changeScale(scaleDelta, 'zoomButton', 'zoomButton', 3) -listen('export-verilog', () => { - logixFunction.generateVerilog(); -}); + // Update display + gridUpdateSet(true) + updateCanvasSet(true) + scheduleUpdate() +} -listen('tutorial', () => { - logixFunction.showTourGuide(); -}); -listen('user-manual', () => { - logixFunction.showUserManual(); -}); +/** + * Get the current zoom level as a slider value + * Inverse of setZoomFromSlider - converts internal scale to slider value + * Useful for initializing or syncing a zoom slider UI + * + * @param {number} minSliderValue - Minimum slider value (default: 0) + * @param {number} maxSliderValue - Maximum slider value (default: 10) + * @param {number} minZoom - Minimum zoom scale (default: 0.5 * DPR) + * @param {number} maxZoom - Maximum zoom scale (default: 4 * DPR) + * @returns {number} The slider value corresponding to current zoom level + * + * Example: const sliderValue = getZoomSliderValue(0, 200) // Returns 0-200 + */ +export function getZoomSliderValue( + minSliderValue = 0, + maxSliderValue = 10, + minZoom = 0.5 * DPR, + maxZoom = 4 * DPR +) { + const inputs = [minSliderValue, maxSliderValue, minZoom, maxZoom, globalScope.scale] + if ( + !inputs.every(Number.isFinite) || + maxSliderValue === minSliderValue || + maxZoom === minZoom + ) { + return minSliderValue + } + + const currentScale = globalScope.scale + + // Clamp current scale to valid range + const clampedScale = Math.max(minZoom, Math.min(maxZoom, currentScale)) + + // Normalize scale to 0-1 range + const normalizedScale = (clampedScale - minZoom) / (maxZoom - minZoom) + + // Map to slider value range + const sliderValue = + minSliderValue + normalizedScale * (maxSliderValue - minSliderValue) + + if (!Number.isFinite(sliderValue)) { + return minSliderValue + } + + return sliderValue +} -listen('learn-digital-circuit', () => { - logixFunction.showDigitalCircuit(); -}); -listen('discussion-forum', () => { - logixFunction.showDiscussionForum(); -});