diff --git a/css/styles.css b/css/styles.css index ebde000..16e4c0f 100644 --- a/css/styles.css +++ b/css/styles.css @@ -48,7 +48,8 @@ canvas { } /* Panels */ -#updatePanel, #modesPanel, #welcomePanel, #aboutPanel, #animationPanel, #colorsetPanel, #patternPanel, #colorPickerPanel { +#updatePanel, #modesPanel, #welcomePanel, #aboutPanel, #animationPanel, +#colorsetPanel, #patternPanel, #colorPickerPanel, #ledSelectPanel, #devicePanel { background-color: #181a1b; border: 1px solid #3e4446; border-radius: 5px; @@ -62,7 +63,11 @@ canvas { #updatePanel { top: 5px; right: 615px; - z-index: 3; +} + +#ledSelectPanel { + top: 438px; + right: 5px; } .device-update-labels { @@ -238,9 +243,15 @@ canvas { justify-content: space-between; } -#modesPanel { +#devicePanel { top: 5px; right: 5px; + z-index: 5; +} + +#modesPanel { + top: 113px; + right: 5px; } #modesPanel .flex-container { @@ -251,7 +262,7 @@ canvas { #deviceConnectionSection { display: flex; - flex-direction: column; + flex-direction: row; justify-content: space-between; /* Adjusts button positioning */ align-items: center; } @@ -265,7 +276,7 @@ canvas { } /* Connection Buttons */ -.mode-list-btn { +.mode-list-btn, .device-control-btn { background-color: #202020; color: #d8d4cf; border: 1px solid #454545; @@ -277,6 +288,19 @@ canvas { width: 52px; } +.device-control-btn { + margin-left: 10px; +} + +.device-control-btn.disconnect { + background-color: #ff4d4d; /* Red background for disconnect */ + color: #fff; /* White text */ +} + +.device-control-btn.disconnect:hover { + background-color: #e63939; /* Darker red on hover */ +} + .mode-list-btn:hover { background-color: #3e4446; } @@ -477,7 +501,7 @@ legend { #modesListScrollContainer { height: 200px; - overflow-y: auto; + overflow-y: scroll; overflow-x: hidden; border: 1px solid #454545; border-radius: 4px; @@ -485,7 +509,6 @@ legend { } #modesListContainer { - height: 398px; width: 95%; } @@ -869,6 +892,7 @@ legend { width: 100%; /* Optional: making the select box full width */ height: auto; /* Adjust height as needed */ user-select: none; + margin-top: 5px; } /* Styling for options */ @@ -994,20 +1018,32 @@ legend { position: relative; display: inline-block; width: 100%; - margin-top: 10px; } -.custom-dropdown-selected { +.custom-dropdown-select { cursor: pointer; padding: 10px; + height: 20px; + line-height: 20px; border: 1px solid #454545; border-radius: 4px; background-color: #181a1b; + color: #d8d4cf; display: flex; align-items: center; + transition: background-color 0.2s, color 0.2s; } -.custom-dropdown-selected img { +.custom-dropdown-select:hover { + background-color: #3e4446; + color: #ffffff; +} + +.custom-dropdown-select:active { + background-color: #2e3436; +} + +.custom-dropdown-select img { width: 24px; height: 24px; margin-right: 10px; @@ -1018,7 +1054,7 @@ legend { position: absolute; background-color: #121212; box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); - z-index: 5; + z-index: 10; width: 100%; } @@ -1811,6 +1847,9 @@ i { height: 40px; /* Fixed height */ box-sizing: border-box; /* Include padding in size */ overflow: none; + + transition: transform 0.2s ease; + position: relative; } .color-box.empty { @@ -1821,6 +1860,40 @@ i { overflow: none; } +.color-box.empty:hover { + background: #222; /* Slightly darker on hover */ + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} + +.color-box .color-hex-input:focus { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} + +.color-box .color-entry:hover { + flex: 0 0 50%; +} + +.color-box.dragging { + margin-top: 50px; + position: static; + pointer-events: none; + border: 1px solid gray; +} + +.color-box.placeholder { + background: rgba(0, 0, 0, 0.1); + border: 2px dashed #ccc; + height: 50px; /* Match the color box height */ +} + +.color-box.over { + transform: translateY(10px); /* Example visual cue */ +} + + + .color-entry { width: 50px; display: inline-block; @@ -1844,21 +1917,6 @@ i { text-align: center; } -.color-box.empty:hover { - background: #222; /* Slightly darker on hover */ - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; -} - -.color-box .color-hex-input:focus { - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; -} - -.color-box .color-entry:hover { - flex: 0 0 50%; -} - /* draggable panels */ @@ -1961,3 +2019,31 @@ i { width: 0; transition: width 0.3s ease; } + + + + +/* Add this at the bottom of your existing CSS */ + +/* Container for the selected LEDs bar */ +#colorset-selected-leds { + display: flex; + flex-wrap: wrap; + align-items: center; + padding: 5px; + margin-bottom: 5px; + border-bottom: 1px solid #3e4446; +} + +/* Individual LED dots */ +.led-dot { + width: 10px; + height: 10px; + background: #444; + border-radius: 50%; + margin: 2px; +} + +.led-dot.selected { + background: #00ff00; /* Green for selected */ +} diff --git a/js/AnimationPanel.js b/js/AnimationPanel.js index 9bbe2a8..4efe625 100644 --- a/js/AnimationPanel.js +++ b/js/AnimationPanel.js @@ -73,6 +73,9 @@ export default class AnimationPanel extends Panel { +
${AnimationPanel.generateControlsContent(controls)} @@ -129,6 +132,7 @@ export default class AnimationPanel extends Panel { { id: 'renderInfinityButton', shape: 'figure8', label: 'Infinity' }, { id: 'renderHeartButton', shape: 'heart', label: 'Heart' }, { id: 'renderBoxButton', shape: 'box', label: 'Box' }, + { id: 'renderCursorButton', shape: 'cursor', label: 'Cursor' }, ]; shapes.forEach(({ id, shape, label }) => { diff --git a/js/ChromalinkPanel.js b/js/ChromalinkPanel.js index cb31156..5c5e484 100644 --- a/js/ChromalinkPanel.js +++ b/js/ChromalinkPanel.js @@ -15,15 +15,15 @@ export default class ChromalinkPanel extends Panel {

Modes:

- - -
-
-
-
+
+ + +
+
+
-
+ `; super('chromalinkPanel', content, 'Chromalink Duo'); @@ -102,9 +102,8 @@ export default class ChromalinkPanel extends Panel { } this.editor.lightshow.vortex.clearModes(); this.editor.lightshow.setLedCount(2); - this.editor.modesPanel.updateSelectedDevice('Duo', true); - //this.editor.modesPanel.renderLedIndicators('Duo'); - this.editor.modesPanel.selectAllLeds(); + this.editor.devicePanel.updateSelectedDevice('Duo', true); + this.editor.modesPanel.refreshModeList(); // update ui document.getElementById('duoIcon').style.display = 'block'; document.getElementById('duoInfo').style.display = 'block'; @@ -112,10 +111,6 @@ export default class ChromalinkPanel extends Panel { document.getElementById('duoModes').textContent = this.duoHeader.numModes; const chromalinkDetails = document.getElementById('chromalinkDetails'); chromalinkDetails.style.display = 'block'; - const flashButton = document.getElementById('chromalinkFlash'); - const updateButton = document.getElementById('chromalinkUpdate'); - flashButton.disabled = false; - updateButton.disabled = false; // give a notification Notification.success('Successfully Chromalinked Duo v' + this.duoHeader.version); } catch (error) { @@ -134,15 +129,11 @@ export default class ChromalinkPanel extends Panel { if (!this.editor.lightshow.vortex.setModes(this.oldModes, false)) { throw new Error('Failed to restore old modes'); } + this.editor.modesPanel.refreshModeList(); this.oldModes = null; - this.editor.modesPanel.updateSelectedDevice('Chromadeck', true); - this.editor.modesPanel.selectAllLeds(); + this.editor.devicePanel.updateSelectedDevice('Chromadeck', true); const chromalinkDetails = document.getElementById('chromalinkDetails'); chromalinkDetails.style.display = 'none'; - const flashButton = document.getElementById('chromalinkFlash'); - const updateButton = document.getElementById('chromalinkUpdate'); - flashButton.disabled = true; - updateButton.disabled = true; document.getElementById('duoIcon').style.display = 'none'; document.getElementById('duoInfo').style.display = 'none'; Notification.success('Successfully Disconnected Chromalink'); diff --git a/js/ColorPickerPanel.js b/js/ColorPickerPanel.js index d17ed04..17b69b3 100644 --- a/js/ColorPickerPanel.js +++ b/js/ColorPickerPanel.js @@ -38,6 +38,11 @@ export default class ColorPickerPanel extends Panel { this.initHueCircle(h); } + hide() { + this.editor.demoModeOnDevice(); + super.hide(); + } + rgbToHsv(r, g, b) { const RGBCol = new this.lightshow.vortexLib.RGBColor(r, g, b); const HSVCol = this.lightshow.vortexLib.rgb_to_hsv_generic(RGBCol); @@ -301,6 +306,7 @@ export default class ColorPickerPanel extends Panel { document.removeEventListener('mousemove', moveEventHandler); document.removeEventListener('mouseup', stopDragging); updateColorUI(false); // Final update after dragging ends + this.editor.demoModeOnDevice(); }; moveHandler(event, isDragging); diff --git a/js/ColorsetPanel.js b/js/ColorsetPanel.js index b6cacd2..f5ce96e 100644 --- a/js/ColorsetPanel.js +++ b/js/ColorsetPanel.js @@ -5,6 +5,7 @@ import ColorPickerPanel from './ColorPickerPanel.js'; export default class ColorsetPanel extends Panel { constructor(editor) { + //
const content = `
`; @@ -46,12 +47,37 @@ export default class ColorsetPanel extends Panel { //console.log('LEDs changed:', this.targetLeds); this.refresh(true); }); - document.addEventListener('deviceConnected', (event) => { - //console.log("Control Panel detected device conneted"); - this.refresh(true); - this.vortexPort.startReading(); - this.editor.demoModeOnDevice(); - }); + document.addEventListener('deviceChange', this.handleDeviceEvent.bind(this)); + } + + handleDeviceEvent(deviceChangeEvent) { + // Access the custom data from `event.detail` + const { deviceEvent, deviceName } = deviceChangeEvent.detail; + if (deviceEvent === 'waiting') { + this.onDeviceWaiting(deviceName); + } else if (deviceEvent === 'connect') { + this.onDeviceConnect(deviceName); + } else if (deviceEvent === 'disconnect') { + this.onDeviceDisconnect(deviceName); + } else if (deviceEvent === 'select') { + this.onDeviceSelected(deviceName); + } + } + + onDeviceSelected(deviceName) { + // nothing yet + } + + onDeviceWaiting(deviceName) { + // nothing yet + } + + onDeviceConnect(deviceName) { + // nothing yet + } + + onDeviceDisconnect(deviceName) { + // nothing yet } setTargetSingles(selectedLeds = null) { @@ -74,9 +100,46 @@ export default class ColorsetPanel extends Panel { } refresh(fromEvent = false) { + //this.refreshSelectedLedsBar(); this.refreshColorset(fromEvent); } + + refreshSelectedLedsBar() { + const selectedLedsBar = document.getElementById('colorset-selected-leds'); + if (!selectedLedsBar) return; + + selectedLedsBar.innerHTML = ''; + + const ledCount = this.lightshow.vortex.engine().leds().ledCount(); + + // Calculate dot size dynamically + const barWidth = selectedLedsBar.clientWidth; + const marginPerDot = 4; // total horizontal space per dot from margin + const desiredDotSize = 10; // base size + const totalNeededWidth = (desiredDotSize + marginPerDot) * ledCount; + + let dotSize = desiredDotSize; + if (totalNeededWidth > barWidth) { + // Scale down if they don't fit + dotSize = Math.floor((barWidth / ledCount) - marginPerDot); + if (dotSize < 4) { + dotSize = 4; // minimum size + } + } + + for (let i = 0; i < ledCount; i++) { + const dot = document.createElement('div'); + dot.classList.add('led-dot'); + dot.style.width = `${dotSize}px`; + dot.style.height = `${dotSize}px`; + if (this.isMulti || this.targetLeds.includes(i)) { + dot.classList.add('selected'); + } + selectedLedsBar.appendChild(dot); + } + } + async refreshColorset(fromEvent = false) { const colorsetElement = document.getElementById('colorset'); const cur = this.lightshow.vortex.engine().modes().curMode(); @@ -89,38 +152,172 @@ export default class ColorsetPanel extends Panel { const set = cur.getColorset(this.targetLed); const numColors = set ? set.numColors() : 0; + let draggingElement = null; + let dragStartIndex = null; + let placeholder = null; + + const createPlaceholder = () => { + placeholder = document.createElement('div'); + placeholder.className = 'color-box placeholder'; + placeholder.style.height = `${draggingElement.offsetHeight}px`; + }; + + const updatePlaceholder = (target) => { + if (!placeholder || !target || placeholder === target) return; + if (target.classList.contains('empty')) return; + colorsetElement.insertBefore(placeholder, target); + }; + + const DRAG_THRESHOLD = 5; // Adjust for better responsiveness + let isDragging = false; + let startX = 0; + let startY = 0; + + const handlePointerDown = (e) => { + const target = e.target.closest('.color-box'); + if (!target) return; + + const isDeleteButton = e.target.classList.contains('delete-color'); + if (isDeleteButton) return; + + draggingElement = target; + dragStartIndex = parseInt(target.dataset.index, 10); + + startX = e.clientX; + startY = e.clientY; + + document.addEventListener('pointermove', checkForDragStart); + document.addEventListener('pointerup', cancelDragStart); + }; + + const checkForDragStart = (e) => { + const movedX = Math.abs(e.clientX - startX); + const movedY = Math.abs(e.clientY - startY); + + if (movedX > DRAG_THRESHOLD || movedY > DRAG_THRESHOLD) { + isDragging = true; + + draggingElement.classList.add('dragging'); + draggingElement.style.position = 'absolute'; + draggingElement.style.zIndex = 1000; + draggingElement.style.pointerEvents = 'none'; + + createPlaceholder(); + updatePlaceholder(draggingElement.nextSibling); + + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + + document.removeEventListener('pointermove', checkForDragStart); + document.removeEventListener('pointerup', cancelDragStart); + } + }; + + const cancelDragStart = () => { + document.removeEventListener('pointermove', checkForDragStart); + document.removeEventListener('pointerup', cancelDragStart); + + draggingElement = null; + isDragging = false; + }; + + const handlePointerMove = (e) => { + if (!draggingElement || !isDragging) return; + + const rect = colorsetElement.getBoundingClientRect(); + draggingElement.style.left = `${e.clientX - rect.left}px`; + draggingElement.style.top = `${e.clientY - rect.top}px`; + + const children = Array.from(colorsetElement.children); + const addColorContainer = children.find(child => child.classList.contains('empty')); + + // Ensure we do not allow the placeholder to interact with the "add color" slot + const target = document.elementFromPoint(e.clientX, e.clientY + 30)?.closest('.color-box:not(.dragging):not(.empty)'); + + if (!target) { + const lastChild = addColorContainer + ? children[children.indexOf(addColorContainer) - 1] // Get the last actual color slot + : children[children.length - 1]; + const rect = lastChild.getBoundingClientRect(); + const midPoint = rect.top + (rect.height / 2); + if (lastChild && e.clientY > midPoint) { + colorsetElement.insertBefore(placeholder, addColorContainer || null); // Place before "add color" if it exists + return; + } + } + if (target) { + updatePlaceholder(target); + } + }; + + const handlePointerUp = () => { + if (!draggingElement || !isDragging) return; + + let dropIndex = Array.from(colorsetElement.children).indexOf(placeholder); + + if (dragStartIndex !== dropIndex) { + const set = cur.getColorset(this.targetLed); + if (dropIndex > dragStartIndex) dropIndex -= 1; + + set.shift(dragStartIndex, dropIndex); + cur.setColorset(set, this.targetLed); + cur.init(); + this.lightshow.vortex.engine().modes().saveCurMode(); + } + + placeholder?.remove(); + placeholder = null; + + draggingElement.classList.remove('dragging'); + draggingElement.style.position = ''; + draggingElement.style.zIndex = ''; + draggingElement.style.pointerEvents = ''; + draggingElement.style.left = ''; + draggingElement.style.top = ''; + draggingElement = null; + isDragging = false; + + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + + this.refreshColorset(); + }; + for (let i = 0; i < numColors; i++) { const container = document.createElement('div'); container.className = 'color-box'; + container.dataset.index = i; const col = set.get(i); const hexColor = `#${((1 << 24) + (col.red << 16) + (col.green << 8) + col.blue).toString(16).slice(1).toUpperCase()}`; - // Create color entry const colorEntry = document.createElement('div'); colorEntry.style.backgroundColor = hexColor; colorEntry.className = 'color-entry'; colorEntry.dataset.index = i; - colorEntry.addEventListener('click', () => - this.editor.colorPickerPanel.open(i, set, this.updateColor.bind(this)) - ); - // Create hex label + // Ensure single-click opens color picker + colorEntry.addEventListener('click', (e) => { + if (!isDragging) { + this.editor.colorPickerPanel.open(i, set, this.updateColor.bind(this)); + } + }); + const hexLabel = document.createElement('label'); hexLabel.textContent = hexColor; - // Create delete button const deleteButton = document.createElement('span'); deleteButton.className = 'delete-color'; deleteButton.dataset.index = i; deleteButton.textContent = '×'; deleteButton.addEventListener('click', (e) => { - e.stopPropagation(); // Prevent triggering color picker + e.stopPropagation(); this.delColor(Number(deleteButton.dataset.index)); document.dispatchEvent(new CustomEvent('patternChange')); }); - // Append elements to container + container.addEventListener('pointerdown', handlePointerDown); + container.appendChild(colorEntry); container.appendChild(hexLabel); container.appendChild(deleteButton); @@ -128,7 +325,6 @@ export default class ColorsetPanel extends Panel { colorsetElement.appendChild(container); } - // Add empty slots for adding colors if (numColors < 8) { const addColorContainer = document.createElement('div'); addColorContainer.className = 'color-box empty'; @@ -141,6 +337,7 @@ export default class ColorsetPanel extends Panel { } } + // Helper: Update Color from Hex Input updateColorHex(index, hexValue) { const cur = this.lightshow.vortex.engine().modes().curMode(); diff --git a/js/DevicePanel.js b/js/DevicePanel.js new file mode 100644 index 0000000..b38673c --- /dev/null +++ b/js/DevicePanel.js @@ -0,0 +1,168 @@ +import Panel from './Panel.js'; +import Notification from './Notification.js'; + +export default class DevicePanel extends Panel { + constructor(editor) { + const content = ` +
+
+
Select Device
+
+ +
+
+ +
+ `; + super('devicePanel', content, 'Device Controls'); + + this.editor = editor; + this.vortexPort = editor.vortexPort; + this.selectedDevice = 'None'; + } + + initialize() { + document.getElementById('connectDeviceButton').addEventListener('click', async () => { + await this.connectDevice(); + }); + + this.addIconsToDropdown(); + + document.getElementById('deviceTypeOptions').addEventListener('click', (event) => { + if (event.target.classList.contains('custom-dropdown-option')) { + const selectedValue = event.target.getAttribute('data-value'); + this.updateSelectedDevice(selectedValue); + } + }); + + document.getElementById('deviceTypeSelected').addEventListener('click', (event) => { + // Prevent dropdown from opening if it's locked + if (event.currentTarget.classList.contains('locked')) { + return; // Do nothing if locked + } + + document.getElementById('deviceTypeOptions').classList.toggle('show'); + }); + } + + async disconnectDevice() { + await this.vortexPort.disconnect(); + this.onDeviceDisconnect(); + } + + async connectDevice() { + try { + await this.vortexPort.requestDevice(deviceEvent => this.deviceChange(deviceEvent)); + } catch (error) { + console.log("Error: " + error); + Notification.failure('Failed to connect: ' + error.message); + } + } + + deviceChange(deviceEvent) { + if (deviceEvent === 'connect') { + this.onDeviceConnect(); + } else if (deviceEvent === 'disconnect') { + this.onDeviceDisconnect(); + } else if (deviceEvent === 'waiting') { + Notification.success("Waiting for device..."); + } else if (deviceEvent === 'select') { + Notification.success(`Selected '${deviceName}`); + } + + // dispatch the device change event with the new device name + document.dispatchEvent(new CustomEvent('deviceChange', { + detail: { deviceEvent, deviceName: this.selectedDevice } + })); + } + + onDeviceConnect() { + Notification.success("Device Connected!"); + + const connectDeviceButton = document.getElementById('connectDeviceButton'); + + // Change button to "Disconnect Device" + //connectDeviceButton.innerHTML = ``; + connectDeviceButton.title = "Disconnect Device"; + //connectDeviceButton.classList.add('disconnect'); // Optional: Add a CSS class for styling + + //// Update event listener for disconnect + //connectDeviceButton.onclick = () => { + // this.vortexPort.disconnectDevice(); + // this.onDeviceDisconnect(); + //}; + + // Lock the dropdown to prevent further changes + document.getElementById('deviceTypeSelected').classList.add('locked'); + + // Update selected device + const deviceName = this.vortexPort.name; + this.updateSelectedDevice(deviceName, true); + this.lockDeviceSelection(true); + } + + onDeviceDisconnect() { + Notification.success("Device Disconnected!"); + + const connectDeviceButton = document.getElementById('connectDeviceButton'); + + // Change button back to "Connect Device" + connectDeviceButton.innerHTML = ``; + connectDeviceButton.title = "Connect Device"; + connectDeviceButton.classList.remove('disconnect'); // Optional: Remove the disconnect styling + + // Restore event listener for connect + connectDeviceButton.onclick = async () => { + await this.connectDevice(); + }; + + // Unlock the dropdown to allow device selection + document.getElementById('deviceTypeSelected').classList.remove('locked'); + + document.dispatchEvent(new CustomEvent('deviceDisconnected')); + + // unlock device selection + this.lockDeviceSelection(false); + } + + addIconsToDropdown() { + const deviceTypeOptions = document.getElementById('deviceTypeOptions'); + deviceTypeOptions.innerHTML = Object.keys(this.editor.devices).map(key => { + const device = this.editor.devices[key]; + return ` +
+ ${device.label} Logo ${device.label} +
`; + }).join(''); + } + + updateSelectedDevice(device) { + const deviceTypeSelected = document.getElementById('deviceTypeSelected'); + const deviceIcon = this.editor.devices[device].icon; + + // Update the UI of the dropdown + deviceTypeSelected.innerHTML = ` + ${device} Logo ${device}`; + + // store the selected device + this.selectedDevice = device; + + // Set LED count based on the device + this.editor.lightshow.setLedCount(this.editor.devices[device].ledCount); + + // Update and show the LED Select Panel + this.editor.ledSelectPanel.updateSelectedDevice(device); + } + + lockDeviceSelection(locked) { + const deviceTypeSelected = document.getElementById('deviceTypeSelected'); + if (locked) { + deviceTypeSelected.classList.add('locked'); + } else { + deviceTypeSelected.classList.remove('locked'); + } + } +} + diff --git a/js/LedSelectPanel.js b/js/LedSelectPanel.js new file mode 100644 index 0000000..4735633 --- /dev/null +++ b/js/LedSelectPanel.js @@ -0,0 +1,468 @@ +import Panel from './Panel.js'; +import Modal from './Modal.js'; +import Notification from './Notification.js'; + +export default class LedSelectPanel extends Panel { + constructor(editor) { + const content = ` +
+ +
+ `; + + super('ledSelectPanel', content, 'LED Selection'); + this.editor = editor; + this.lightshow = editor.lightshow; + this.vortexPort = editor.vortexPort; + } + + initialize() { + document.getElementById('ledsFieldset').style.display = 'none'; + + // Event listeners for LED list and controls + const ledList = document.getElementById('ledList'); + ledList.addEventListener('change', () => this.handleLedSelectionChange()); + ledList.addEventListener('click', () => this.handleLedSelectionChange()); + + document.getElementById('selectAllLeds').addEventListener('click', () => this.selectAllLeds()); + document.getElementById('selectNoneLeds').addEventListener('click', () => this.selectNoneLeds()); + document.getElementById('invertLeds').addEventListener('click', () => this.invertLeds()); + document.getElementById('evenLeds').addEventListener('click', () => this.evenLeds()); + document.getElementById('oddLeds').addEventListener('click', () => this.oddLeds()); + document.getElementById('randomLeds').addEventListener('click', () => this.randomLeds()); + + const deviceImageContainer = document.getElementById('deviceImageContainer'); + deviceImageContainer.addEventListener('mousedown', (event) => this.onMouseDown(event)); + document.addEventListener('mousemove', (event) => this.onMouseMove(event)); + document.addEventListener('mouseup', (event) => this.onMouseUp(event)); + + // Listen to pattern changes to refresh LED indicators as needed + document.addEventListener('patternChange', () => this.updateLedIndicators()); + + // hide till device connects + this.hide(); + } + + updateSelectedDevice(device) { + const deviceTypeSelected = document.getElementById('deviceTypeSelected'); + const ledsFieldset = document.getElementById('ledsFieldset'); + const modesListScrollContainer = document.getElementById('modesListScrollContainer'); + + const deviceIcon = this.editor.devices[device].icon; + + if (device === 'None') { + deviceTypeSelected.innerHTML = 'Select Device'; + document.getElementById('deviceTypeOptions').classList.remove('show'); + this.lightshow.setLedCount(1); + if (modesListScrollContainer) { + modesListScrollContainer.style.height = '200px'; + } + document.getElementById('spread_div').style.display = 'none'; + ledsFieldset.style.display = 'none'; + this.hide(); + return; + } + + document.getElementById('spread_div').style.display = 'block'; + + deviceTypeSelected.innerHTML = ` + ${device} Logo + ${device} + `; + + this.lightshow.setLedCount(this.editor.devices[device].ledCount); + + if (modesListScrollContainer) { + modesListScrollContainer.style.height = '200px'; + } + ledsFieldset.style.display = 'block'; + document.getElementById('deviceTypeOptions').classList.remove('show'); + this.renderLedIndicators(device); + this.selectAllLeds(); + this.refreshLedList(); + this.show(); + } + + addIconsToDropdown() { + const deviceTypeOptions = document.getElementById('deviceTypeOptions'); + deviceTypeOptions.innerHTML = Object.keys(this.editor.devices).map(key => { + const device = this.editor.devices[key]; + return `
+ ${device.label} Logo + ${device.label} +
+ `; + }).join(''); + } + + async getLedPositions(deviceName) { + try { + const cacheBuster = '?v=' + new Date().getTime(); + const response = await fetch(`public/data/${deviceName.toLowerCase()}-led-positions.json${cacheBuster}`); + const data = await response.json(); + return data; + } catch (error) { + console.error(`Error loading LED positions for ${deviceName}:`, error); + return { points: [], original_width: 1, original_height: 1 }; + } + } + + async renderLedIndicators(deviceName = null) { + const ledsFieldset = document.getElementById('ledsFieldset'); + const deviceImageContainer = document.getElementById('deviceImageContainer'); + const ledControls = document.getElementById('ledControls'); + const ledList = document.getElementById('ledList'); + + if (!deviceName || deviceName === 'None') { + ledsFieldset.style.display = 'none'; + this.hide(); + return; + } + + ledsFieldset.style.display = 'block'; + deviceImageContainer.innerHTML = ''; + ledList.style.display = 'block'; + ledControls.style.display = 'flex'; + deviceImageContainer.innerHTML = ''; + + const overlay = document.createElement('div'); + overlay.classList.add('led-overlay'); + deviceImageContainer.appendChild(overlay); + + const deviceData = await this.getLedPositions(deviceName); + const deviceImageSrc = this.editor.devices[deviceName].image; + + if (deviceImageSrc) { + const deviceImage = document.createElement('img'); + deviceImage.src = deviceImageSrc + '?v=' + new Date().getTime(); + deviceImage.style.display = 'block'; + deviceImage.style.width = '100%'; + deviceImage.style.height = 'auto'; + + deviceImage.onload = () => { + const scaleX = deviceImageContainer.clientWidth / deviceData.original_width; + const scaleY = deviceImageContainer.clientHeight / deviceData.original_height; + + const selectedLeds = this.getSelectedLeds(); + + deviceData.points.forEach((point, index) => { + const ledIndicator = document.createElement('div'); + ledIndicator.classList.add('led-indicator'); + if (index in selectedLeds) { + ledIndicator.classList.add('selected'); + } + ledIndicator.style.left = `${point.x * scaleX}px`; + ledIndicator.style.top = `${point.y * scaleY}px`; + ledIndicator.dataset.ledIndex = index; + + overlay.appendChild(ledIndicator); + }); + }; + + deviceImageContainer.appendChild(deviceImage); + } + } + + refreshLedList() { + const ledList = document.getElementById('ledList'); + const ledControls = document.getElementById('ledControls'); + const deviceImageContainer = document.getElementById('deviceImageContainer'); + + let selectedLeds = Array.from(ledList.selectedOptions).map(option => option.value); + const cur = this.lightshow.vortex.engine().modes().curMode(); + + if (!cur) { + ledList.innerHTML = ''; + return; + } + + this.clearLedList(); + this.clearLedSelections(); + + if (!cur.isMultiLed()) { + for (let pos = 0; pos < this.lightshow.vortex.numLedsInMode(); ++pos) { + let ledName = this.lightshow.vortex.ledToString(pos) + " (" + this.lightshow.vortex.getPatternName(pos) + ")"; + const option = document.createElement('option'); + option.value = pos; + option.textContent = ledName; + ledList.appendChild(option); + } + ledControls.style.display = 'flex'; + deviceImageContainer.querySelectorAll('.led-indicator').forEach(indicator => { + indicator.style.backgroundColor = ''; + }); + if (selectedLeds.includes("multi")) { + this.selectAllLeds(); + selectedLeds = Array.from(ledList.selectedOptions).map(option => option.value); + } + } else { + let ledName = "Multi led (" + this.lightshow.vortex.getPatternName(this.lightshow.vortex.engine().leds().ledMulti()) + ")"; + const option = document.createElement('option'); + option.value = 'multi'; + option.textContent = ledName; + ledList.appendChild(option); + selectedLeds = [ "multi" ]; + + // Disable LED controls + ledControls.style.display = 'none'; + + // All LED indicators green + deviceImageContainer.querySelectorAll('.led-indicator').forEach(indicator => { + indicator.classList.add('selected'); + }); + } + + if (!selectedLeds.length && ledList.options.length > 0) { + selectedLeds = [ "0" ]; + } + + this.applyLedSelections(selectedLeds); + } + + clearLedList() { + const ledList = document.getElementById('ledList'); + ledList.innerHTML = ''; + } + + clearLedSelections() { + const ledList = document.getElementById('ledList'); + for (let option of ledList.options) { + option.selected = false; + } + } + + applyLedSelections(selectedLeds) { + const ledList = document.getElementById('ledList'); + for (let option of ledList.options) { + option.selected = selectedLeds.includes(option.value); + } + } + + handleLedSelectionChange() { + this.lightshow.targetLeds = this.getSelectedLeds(); + document.dispatchEvent(new CustomEvent('ledsChange', { detail: this.lightshow.targetLeds })); + this.updateLedIndicators(this.lightshow.targetLeds); + } + + getSelectedLeds() { + const ledList = document.getElementById('ledList'); + return Array.from(ledList.selectedOptions).map(option => option.value); + } + + updateLedIndicators(selectedLeds = null) { + if (!selectedLeds) { + selectedLeds = this.getSelectedLeds(); + } + const cur = this.lightshow.vortex.engine().modes().curMode(); + const ledIndicators = document.querySelectorAll('.led-indicator'); + if (!ledIndicators) { + return; + } + ledIndicators.forEach(indicator => { + const index = indicator.dataset.ledIndex; + if (cur && cur.isMultiLed()) { + indicator.classList.add('selected'); + } else { + if (selectedLeds.includes(index.toString())) { + indicator.classList.add('selected'); + } else { + indicator.classList.remove('selected'); + } + indicator.style.backgroundColor = ''; + } + }); + } + + selectAllLeds() { + const ledList = document.getElementById('ledList'); + for (let option of ledList.options) { + option.selected = true; + } + this.handleLedSelectionChange(); + } + + selectNoneLeds() { + const ledList = document.getElementById('ledList'); + for (let option of ledList.options) { + option.selected = false; + } + this.handleLedSelectionChange(); + } + + invertLeds() { + const ledList = document.getElementById('ledList'); + for (let option of ledList.options) { + option.selected = !option.selected; + } + this.handleLedSelectionChange(); + } + + evenLeds() { + const ledList = document.getElementById('ledList'); + for (let i = 0; i < ledList.options.length; i++) { + ledList.options[i].selected = (i % 2 === 0); + } + this.handleLedSelectionChange(); + } + + oddLeds() { + const ledList = document.getElementById('ledList'); + for (let i = 0; i < ledList.options.length; i++) { + ledList.options[i].selected = (i % 2 !== 0); + } + this.handleLedSelectionChange(); + } + + randomLeds(probability = 0.5) { + const ledList = document.getElementById('ledList'); + for (let option of ledList.options) { + option.selected = Math.random() < probability; + } + this.handleLedSelectionChange(); + } + + // Mouse handling for LED selection box + onMouseDown(event) { + if (event.button !== 0) return; // left mouse + event.preventDefault(); + + this.isDragging = true; + this.startX = event.clientX; + this.startY = event.clientY; + this.currentX = event.clientX; + this.currentY = event.clientY; + this.dragStartTime = Date.now(); + + this.selectionBox = document.createElement('div'); + this.selectionBox.classList.add('selection-box'); + document.body.appendChild(this.selectionBox); + + this.selectionBox.style.left = `${this.startX}px`; + this.selectionBox.style.top = `${this.startY}px`; + } + + onMouseMove(event) { + if (!this.isDragging) return; + event.preventDefault(); + + this.currentX = event.clientX; + this.currentY = event.clientY; + + this.selectionBox.style.width = `${Math.abs(this.currentX - this.startX)}px`; + this.selectionBox.style.height = `${Math.abs(this.currentY - this.startY)}px`; + this.selectionBox.style.left = `${Math.min(this.currentX, this.startX)}px`; + this.selectionBox.style.top = `${Math.min(this.currentY, this.startY)}px`; + } + + onMouseUp(event) { + if (event.button !== 0) return; + event.preventDefault(); + + if (!this.selectionBox) { + this.isDragging = false; + return; + } + + if (this.selectionBox) { + document.body.removeChild(this.selectionBox); + this.selectionBox = null; + } + + if (!this.isDragging) { + return; + } + + this.isDragging = false; + + const cur = this.lightshow.vortex.engine().modes().curMode(); + if (cur && cur.isMultiLed()) { + Notification.failure("To select LEDs switch to a single led pattern.", 3000); + return; + } + + const deviceImageContainer = document.getElementById('deviceImageContainer'); + const rect = deviceImageContainer.getBoundingClientRect(); + + let startX = Math.min(this.startX, this.currentX) - rect.left; + let startY = Math.min(this.startY, this.currentY) - rect.top; + let endX = Math.max(this.startX, this.currentX) - rect.left; + let endY = Math.max(this.startY, this.currentY) - rect.top; + + // Ensure within container + startX = Math.max(startX, 0); + startY = Math.max(startY, 0); + endX = Math.min(endX, rect.width); + endY = Math.min(endY, rect.height); + + const isClick = Math.abs(startX - endX) <= 3 && Math.abs(startY - endY) <= 3; + const clickX = (startX + endX) / 2; + const clickY = (startY + endY) / 2; + + document.querySelectorAll('.led-indicator').forEach(indicator => { + const ledRect = indicator.getBoundingClientRect(); + const ledX1 = (ledRect.left - rect.left) + 1; + const ledY1 = (ledRect.top - rect.top) + 1; + const ledX2 = (ledRect.right - rect.left) - 1; + const ledY2 = (ledRect.bottom - rect.top) - 1; + const ledMidX = ledX1 + (ledRect.width / 2); + const ledMidY = ledY1 + (ledRect.height / 2); + + const ledList = document.getElementById('ledList'); + let option = ledList.querySelector(`option[value='${indicator.dataset.ledIndex}']`); + + let withinBounds = false; + + if (isClick) { + withinBounds = clickX >= ledX1 && clickX <= ledX2 && clickY >= ledY1 && clickY <= ledY2; + } else { + withinBounds = + (ledX1 >= startX && ledX1 <= endX && ledY1 >= startY && ledY1 <= endY) || + (ledX2 >= startX && ledX2 <= endX && ledY1 >= startY && ledY1 <= endY) || + (ledX1 >= startX && ledX1 <= endX && ledY2 >= startY && ledY2 <= endY) || + (ledX2 >= startX && ledX2 <= endX && ledY2 >= startY && ledY2 <= endY) || + (ledMidX >= startX && ledMidX <= endX && ledMidY >= startY && ledMidY <= endY); + } + + if (withinBounds) { + this.selectLed(indicator.dataset.ledIndex, !event.ctrlKey); + option.selected = !event.ctrlKey; + if (option.selected) { + indicator.classList.add('selected'); + } else { + indicator.classList.remove('selected'); + } + } else if (!event.shiftKey && !event.ctrlKey) { + this.selectLed(indicator.dataset.ledIndex, false); + if (option) option.selected = false; + indicator.classList.remove('selected'); + } + }); + + this.handleLedSelectionChange(); + } + + selectLed(index, selected = true) { + const ledList = document.getElementById('ledList'); + let option = ledList.querySelector(`option[value='${index}']`); + if (!option) return; // no adding new options + option.selected = selected; + + this.handleLedSelectionChange(); + } +} + diff --git a/js/Lightshow.js b/js/Lightshow.js index 9bff646..87ea78a 100644 --- a/js/Lightshow.js +++ b/js/Lightshow.js @@ -36,10 +36,55 @@ export default class Lightshow { this.applyModeData(); this.targetLeds = [0]; + this.cursorPosition = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; // Center default + this.targetPosition = { x: this.cursorPosition.x, y: this.cursorPosition.y }; + this.velocity = { x: 0, y: 0 }; + this.friction = 0.8; // Friction for glide effect + this.isDragging = false; + + // Event listeners for desktop and mobile + this.addInteractionListeners(); + // Initialize histories for each LED this.updateHistories(); } + addInteractionListeners() { + // Mouse interactions + window.addEventListener('mousemove', (event) => { + this.targetPosition.x = event.clientX; + this.targetPosition.y = event.clientY; + }); + + window.addEventListener('mousedown', (event) => { + this.isDragging = true; + this.velocity = { x: 0, y: 0 }; // Reset momentum + }); + + window.addEventListener('mouseup', () => { + this.isDragging = false; // Release drag + }); + + // Touch interactions for mobile + window.addEventListener('touchstart', (event) => { + const touch = event.touches[0]; + this.targetPosition.x = touch.clientX; + this.targetPosition.y = touch.clientY; + this.isDragging = true; + this.velocity = { x: 0, y: 0 }; + }); + + window.addEventListener('touchmove', (event) => { + const touch = event.touches[0]; + this.targetPosition.x = touch.clientX; + this.targetPosition.y = touch.clientY; + }); + + window.addEventListener('touchend', () => { + this.isDragging = false; + }); + } + setLedCount(count) { this.vortex.setLedCount(count); this.updateHistories(); @@ -133,12 +178,87 @@ export default class Lightshow { // function to set the shape setShape(shape) { if (this.currentShape === shape) { - this.direction *= -1; + this.direction *= -1; // Reverse direction for the same shape } else { this.currentShape = shape; } + + if (shape === 'cursor') { + this.enableCursorFollow(); + } else { + this.disableCursorFollow(); + } + } + + enableCursorFollow() { + if (!this.cursorMoveListener) { + this.cursorPosition = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; // Default center + this.cursorMoveListener = (event) => { + this.cursorPosition.x = event.clientX; + this.cursorPosition.y = event.clientY; + }; + window.addEventListener('mousemove', this.cursorMoveListener); + } + } + + disableCursorFollow() { + if (this.cursorMoveListener) { + window.removeEventListener('mousemove', this.cursorMoveListener); + this.cursorMoveListener = null; + } + this.targetPosition = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + this.velocity = { x: 0, y: 0 }; + this.cursorPosition = { x: this.targetPosition.x, y: this.targetPosition.y }; + } + + + feedCursorPoints() { + const { x: currentX, y: currentY } = this.cursorPosition; + + const frac = (this.tickRate / 300); + + // Apply velocity for smooth movement + this.velocity.x += (this.targetPosition.x - currentX) * (frac * 3); // Acceleration toward target + this.velocity.y += (this.targetPosition.y - currentY) * (frac * 3); + + // constant fraction of tickrate used to effect velocity + const ratio = frac + this.friction; + + this.velocity.x *= this.friction; // Apply friction but effected by speed + this.velocity.y *= this.friction; + + this.cursorPosition.x += this.velocity.x; + this.cursorPosition.y += this.velocity.y; + + const { x: cursorX, y: cursorY } = this.cursorPosition; + + for (let i = 0; i < (this.tickRate / 2); i++) { + const leds = this.vortexLib.RunTick(this.vortex); + if (!leds) { + continue; + } + + while (this.histories.length < leds.length) { + this.histories.push([]); + } + + leds.forEach((col, index) => { + const angle = (Math.PI * 2 * index) / leds.length; + const radius = 30 + index * this.spread; + + const x = cursorX + radius * Math.cos(angle); + const y = cursorY + radius * Math.sin(angle); + + if (!col) col = { red: 0, green: 0, blue: 0 }; + + this.histories[index].push({ x, y, color: col }); + }); + } } + + + draw() { switch (this.currentShape) { case 'circle': @@ -153,6 +273,9 @@ export default class Lightshow { case 'box': this.feedBoxPoints(); break; + case 'cursor': + this.feedCursorPoints(); + break; default: console.warn('Unknown shape:', this.currentShape); return; diff --git a/js/ModesPanel.js b/js/ModesPanel.js index 93480c2..4de021a 100644 --- a/js/ModesPanel.js +++ b/js/ModesPanel.js @@ -7,87 +7,42 @@ import ChromalinkPanel from './ChromalinkPanel.js'; export default class ModesPanel extends Panel { constructor(editor) { const content = ` -
-
- - - - - - -
-
-
- -
-
-
-
Select Device
-
-
- Orbit Logo - Orbit -
-
+
+ + + + + +
+
+
+
-
`; - - super('modesPanel', content, 'Modes and Device Controls'); + super('modesPanel', content, 'Modes List'); this.editor = editor; this.lightshow = editor.lightshow; this.vortexPort = editor.vortexPort; this.shareModal = new Modal('share'); this.exportModal = new Modal('export'); this.importModal = new Modal('import'); - - // note changing this will not impact which device is shown, this just records - // which device was selected or connected for reference later - this.selectedDevice = 'None'; - this.devices = { - 'None': { image: 'public/images/none-logo-square-512.png', icon: 'public/images/none-logo-square-64.png', label: 'None', ledCount: 1 }, - 'Orbit': { image: 'public/images/orbit.png', icon: 'public/images/orbit-logo-square-64.png', label: 'Orbit', ledCount: 28 }, - 'Handle': { image: 'public/images/handle.png', icon: 'public/images/handle-logo-square-64.png', label: 'Handle', ledCount: 3 }, - 'Gloves': { image: 'public/images/gloves.png', icon: 'public/images/gloves-logo-square-64.png', label: 'Gloves', ledCount: 10 }, - 'Chromadeck': { image: 'public/images/chromadeck.png', icon: 'public/images/chromadeck-logo-square-64.png', label: 'Chromadeck', ledCount: 20 }, - 'Spark': { image: 'public/images/spark.png', icon: 'public/images/spark-logo-square-64.png', label: 'Spark', ledCount: 6 }, - 'Duo': { image: 'public/images/duo.png', icon: 'public/images/duo-logo-square-64.png', label: 'Duo', ledCount: 2 } - }; } initialize() { // Hide device connection section and leds fieldset initially //document.getElementById('deviceConnectionSection').style.display = 'block'; - document.getElementById('ledsFieldset').style.display = 'none'; + //document.getElementById('ledsFieldset').style.display = 'none'; // optionally initialize the chromalink now: //this.chromalinkPanel = new ChromalinkPanel(this.editor, this); @@ -134,170 +89,20 @@ export default class ModesPanel extends Panel { transmitButton.addEventListener('click', () => this.editor.transmitVL()); document.addEventListener('patternChange', () => this.refresh(true)); + document.addEventListener('deviceChange', this.handleDeviceEvent.bind(this)); - document.getElementById('connectDeviceButton').addEventListener('click', async () => { - try { - // TODO: check for the thing - // // Check if WebSerial is available in the browser - //if ('serial' in navigator) { - //} else { - // document.getElementById('connectDevice').style.display = 'none'; - // document.getElementById('deviceConnectMessage').style.display = 'none'; - // document.getElementById('unsupportedBrowserMessage').style.display = 'block'; - //} - - //
- // - //
- - await this.vortexPort.requestDevice(deviceEvent => this.deviceChange(deviceEvent)); - } catch (error) { - console.log("Error: " + error); - Notification.failure('Failed to connect: ' + error.message); - } - }); - - const ledList = document.getElementById('ledList'); - ledList.addEventListener('change', () => this.handleLedSelectionChange()); - ledList.addEventListener('click', () => this.handleLedSelectionChange()); - - document.getElementById('selectAllLeds').addEventListener('click', () => this.selectAllLeds()); - document.getElementById('selectNoneLeds').addEventListener('click', () => this.selectNoneLeds()); - document.getElementById('invertLeds').addEventListener('click', () => this.invertLeds()); - document.getElementById('evenLeds').addEventListener('click', () => this.evenLeds()); - document.getElementById('oddLeds').addEventListener('click', () => this.oddLeds()); - document.getElementById('randomLeds').addEventListener('click', () => this.randomLeds()); - - const deviceImageContainer = document.getElementById('deviceImageContainer'); - deviceImageContainer.addEventListener('mousedown', (event) => this.onMouseDown(event)); - document.addEventListener('mousemove', (event) => this.onMouseMove(event)); - document.addEventListener('mouseup', (event) => this.onMouseUp(event)); - - document.addEventListener('deviceTypeChange', (event) => { - this.renderLedIndicators(this.selectedDevice); - this.handleLedSelectionChange(); - }); - - // Initialize dropdown with icons - this.addIconsToDropdown(); - // Add event listener for device type selection - document.getElementById('deviceTypeOptions').addEventListener('click', (event) => { - if (event.target && event.target.classList.contains('custom-dropdown-option')) { - const selectedValue = event.target.getAttribute('data-value'); - this.updateSelectedDevice(selectedValue); - } - }); - - // Add event listener for the selected device type dropdown - document.getElementById('deviceTypeSelected').addEventListener('click', () => { - if (event.currentTarget.classList.contains('locked')) { - event.stopPropagation(); // Prevent further propagation of the click event - event.preventDefault(); // Prevent the default action associated with the click event - return; - } - document.getElementById('deviceTypeOptions').classList.toggle('show'); - }); - this.refreshModeList(); - } - - lockDeviceSelection(locked) { - const deviceTypeSelected = document.getElementById('deviceTypeSelected'); - - if (locked) { - deviceTypeSelected.classList.add('locked'); - } else { - deviceTypeSelected.classList.remove('locked'); - } - } - - updateSelectedDevice(device, lock = false) { - const deviceTypeSelected = document.getElementById('deviceTypeSelected'); - const modesListScrollContainer = document.getElementById('modesListScrollContainer'); - - const deviceIcon = this.devices[device].icon; - - this.selectedDevice = device; - document.dispatchEvent(new CustomEvent('deviceTypeChange', { detail: this.selectedDevice })); - - if (device === 'None') { - deviceTypeSelected.innerHTML = 'Select Device'; - document.getElementById('deviceTypeOptions').classList.remove('show'); - this.lightshow.setLedCount(1); // Reset to default LED count - modesListScrollContainer.style.height = '200px'; - - // hide the spread slider - document.getElementById('spread_div').style.display = 'none'; - - // Hide the LED selection fieldset - ledsFieldset.style.display = 'none'; - - // set the lock - this.lockDeviceSelection(lock); - - return; - } - - // display the spread slider - document.getElementById('spread_div').style.display = 'block'; - - deviceTypeSelected.innerHTML = ` - ${device} Logo - ${device} - `; - - this.lightshow.setLedCount(this.devices[device].ledCount); - if (device === 'None') { - document.getElementById('deviceTypeOptions').classList.add('show'); - // make the modes list long again - if (modesListScrollContainer) { - modesListScrollContainer.style.height = '200px'; - } - // hide led selection - ledsFieldset.style.display = 'none'; - // all done - return - } - - // otherwise handle all other devices with shorter modes list - if (modesListScrollContainer) { - modesListScrollContainer.style.height = '200px'; - } - ledsFieldset.style.display = 'block'; // Show the fieldset for other devices - document.getElementById('deviceTypeOptions').classList.remove('show'); - this.lockDeviceSelection(lock); this.refreshModeList(); } - addIconsToDropdown() { - const deviceTypeOptions = document.getElementById('deviceTypeOptions'); - deviceTypeOptions.innerHTML = Object.keys(this.devices).map(key => { - const device = this.devices[key]; - return `
- ${device.label} Logo - ${device.label} -
- `; - }).join(''); - } - - showDeviceConnectionSection() { - //document.getElementById('deviceConnectionSection').style.display = 'block'; - document.getElementById('ledsFieldset').style.display = 'block'; - } - - async onDeviceConnect() { + onDeviceConnect(deviceName) { console.log("Device connected: " + this.vortexPort.name); - const ledCount = this.devices[this.vortexPort.name].ledCount; + const ledCount = this.editor.devices[this.vortexPort.name].ledCount; if (ledCount !== undefined) { this.lightshow.setLedCount(ledCount); console.log(`Set LED count to ${ledCount} for ${this.vortexPort.name}`); } else { console.log(`Device name ${this.vortexPort.name} not recognized`); } - document.dispatchEvent(new CustomEvent('deviceConnected')); this.refresh(true); //Notification.success(this.vortexPort.name + ' Connected!'); //let statusMessage = document.getElementById('deviceStatus'); @@ -320,6 +125,8 @@ export default class ModesPanel extends Panel { //if (!this.editor.updatePanel.isVisible && this.vortexPort.name === 'Spark') { // this.editor.updatePanel.show(); //} + + console.log("Checking version..."); // check version numbers this.editor.checkVersion(this.vortexPort.name, this.vortexPort.version); @@ -339,185 +146,23 @@ export default class ModesPanel extends Panel { if (modesListScrollContainer) { modesListScrollContainer.style.height = '200px'; } - - // update device selection and lock it so it can't change - this.updateSelectedDevice(this.vortexPort.name, true); - - this.selectAllLeds(); - } - - showOutdatedFirmwareNotification(device, version, latestVersion, downloadUrl) { - const modalId = 'outdatedFirmwareModal' + device; - - // Check if the modal already exists - let modal = Modal.getExistingModal(modalId); - - if (!modal) { - modal = new Modal(modalId); - } - - const lowerDevice = device.toLowerCase(); - const content = ` -
-

Your device is out of date, please install the latest version

-

Device: ${device}

-

Current Version: ${version}

-

Latest Version: ${latestVersion}

- -
- `; - - modal.show({ title: device + ' Firmware Update', blurb: content }); } - // Add the rest of the unchanged methods here... - onMouseDown(event) { - if (event.button !== 0) return; // Only react to left mouse button - event.preventDefault(); - - this.isDragging = true; - this.startX = event.clientX; - this.startY = event.clientY; - this.currentX = event.clientX; - this.currentY = event.clientY; - this.dragStartTime = Date.now(); // Record the time when dragging started - - this.selectionBox = document.createElement('div'); - this.selectionBox.classList.add('selection-box'); - document.body.appendChild(this.selectionBox); - - this.selectionBox.style.left = `${this.startX}px`; - this.selectionBox.style.top = `${this.startY}px`; - } - - onMouseMove(event) { - if (!this.isDragging) return; - event.preventDefault(); - - this.currentX = event.clientX; - this.currentY = event.clientY; - - this.selectionBox.style.width = `${Math.abs(this.currentX - this.startX)}px`; - this.selectionBox.style.height = `${Math.abs(this.currentY - this.startY)}px`; - this.selectionBox.style.left = `${Math.min(this.currentX, this.startX)}px`; - this.selectionBox.style.top = `${Math.min(this.currentY, this.startY)}px`; - } - - onMouseUp(event) { - if (event.button !== 0) return; // Only react to left mouse button - event.preventDefault(); - - if (!this.selectionBox) { - this.isDragging = false; - return; - } - - if (this.selectionBox) { - document.body.removeChild(this.selectionBox); - this.selectionBox = null; - } - - if (!this.isDragging) { - return; - } - - this.isDragging = false; - if (this.lightshow.vortex.engine().modes().curMode().isMultiLed()) { - Notification.failure("To select LEDs switch to a single led pattern.", 3000); - // Prevent selection if multi-LED pattern is applied - return; - } - - const deviceImageContainer = document.getElementById('deviceImageContainer'); - const rect = deviceImageContainer.getBoundingClientRect(); - - let startX = Math.min(this.startX, this.currentX) - rect.left; - let startY = Math.min(this.startY, this.currentY) - rect.top; - let endX = Math.max(this.startX, this.currentX) - rect.left; - let endY = Math.max(this.startY, this.currentY) - rect.top; - - // Ensure values are within bounds of the container - startX = Math.max(startX, 0); - startY = Math.max(startY, 0); - endX = Math.min(endX, rect.width); - endY = Math.min(endY, rect.height); - - const isClick = Math.abs(startX - endX) <= 3 && Math.abs(startY - endY) <= 3; - const clickX = (startX + endX) / 2; - const clickY = (startY + endY) / 2; - - document.querySelectorAll('.led-indicator').forEach(indicator => { - const ledRect = indicator.getBoundingClientRect(); - const ledX1 = (ledRect.left - rect.left) + 1; - const ledY1 = (ledRect.top - rect.top) + 1; - const ledX2 = (ledRect.right - rect.left) - 1; - const ledY2 = (ledRect.bottom - rect.top) - 1; - const ledMidX = ledX1 + (ledRect.width / 2); - const ledMidY = ledY1 + (ledRect.height / 2); - - const ledList = document.getElementById('ledList'); - let option = ledList.querySelector(`option[value='${indicator.dataset.ledIndex}']`); - - let withinBounds = false; - - if (isClick) { - // Check if click is within the bounds of the indicator - withinBounds = clickX >= ledX1 && clickX <= ledX2 && clickY >= ledY1 && clickY <= ledY2; - } else { - // Check if any part of the indicator is within the selection box - withinBounds = - (ledX1 >= startX && ledX1 <= endX && ledY1 >= startY && ledY1 <= endY) || - (ledX2 >= startX && ledX2 <= endX && ledY1 >= startY && ledY1 <= endY) || - (ledX1 >= startX && ledX1 <= endX && ledY2 >= startY && ledY2 <= endY) || - (ledX2 >= startX && ledX2 <= endX && ledY2 >= startY && ledY2 <= endY) || - (ledMidX >= startX && ledMidX <= endX && ledMidY >= startY && ledMidY <= endY); - } - - if (withinBounds) { - this.selectLed(indicator.dataset.ledIndex, !event.ctrlKey); - option.selected = !event.ctrlKey; - if (option.selected) { - indicator.classList.add('selected'); - } else { - indicator.classList.remove('selected'); - } - } else if (!event.shiftKey && !event.ctrlKey) { - this.selectLed(indicator.dataset.ledIndex, false); - option.selected = false; - indicator.classList.remove('selected'); - } - }); - - // Manually call the handler - this.handleLedSelectionChange(); - } - - selectLed(index, selected = true) { - const ledList = document.getElementById('ledList'); - let option = ledList.querySelector(`option[value='${index}']`); - if (!option) { - // don't add it if it's not there - return; - } - option.selected = selected; - - this.handleLedSelectionChange(); - } - - deviceChange(deviceEvent) { + handleDeviceEvent(deviceChangeEvent) { + // Access the custom data from `event.detail` + const { deviceEvent, deviceName } = deviceChangeEvent.detail; if (deviceEvent === 'waiting') { - this.onDeviceWaiting(); + this.onDeviceWaiting(deviceName); } else if (deviceEvent === 'connect') { - this.onDeviceConnect(); + this.onDeviceConnect(deviceName); } else if (deviceEvent === 'disconnect') { - this.onDeviceDisconnect(); + this.onDeviceDisconnect(deviceName); + } else if (deviceEvent === 'select') { + this.onDeviceSelected(deviceName); } } - onDeviceWaiting() { + onDeviceWaiting(deviceName) { Notification.success("Waiting for device..."); //let statusMessage = document.getElementById('deviceStatus'); //statusMessage.textContent = 'Waiting for device...'; @@ -525,46 +170,7 @@ export default class ModesPanel extends Panel { //statusMessage.classList.remove('status-success', 'status-failure'); } - toggleLed(index) { - const cur = this.lightshow.vortex.engine().modes().curMode(); - if (cur.isMultiLed()) return; // Prevent toggling if multi-LED pattern is applied - - const ledIndicator = document.querySelector(`.led-indicator[data-led-index='${index}']`); - if (ledIndicator) { - ledIndicator.classList.toggle('selected'); - } - - // Update internal state without triggering change event - const ledList = document.getElementById('ledList'); - const option = ledList.querySelector(`option[value='${index}']`); - if (!option) { - // Create option if it doesn't exist - const newOption = document.createElement('option'); - newOption.value = index; - newOption.textContent = `LED ${index}`; - ledList.appendChild(newOption); - newOption.selected = true; - } else { - option.selected = !option.selected; - } - - // Directly call the handler to avoid cyclic event loop - this.handleLedSelectionChange(); - } - - async getLedPositions(deviceName) { - try { - const cacheBuster = '?v=' + new Date().getTime(); - const response = await fetch(`public/data/${deviceName.toLowerCase()}-led-positions.json${cacheBuster}`); - const data = await response.json(); - return data; - } catch (error) { - console.error(`Error loading LED positions for ${deviceName}:`, error); - return { points: [], original_width: 1, original_height: 1 }; - } - } - - onDeviceDisconnect() { + onDeviceDisconnect(deviceName) { console.log("Device disconnected"); Notification.success(this.vortexPort.name + ' Disconnected!'); @@ -578,223 +184,19 @@ export default class ModesPanel extends Panel { //statusMessage.textContent = this.vortexPort.name + ' Disconnected!'; //statusMessage.classList.remove('status-success', 'status-pending'); //statusMessage.classList.add('status-failure'); - this.lockDeviceSelection(false); + //this.lockDeviceSelection(false); } - refresh(fromEvent = false) { - this.refreshModeList(fromEvent); - this.updateLedIndicators(); // Ensure indicators are updated + onDeviceSelected(devicename) { + // } - refreshLedList(fromEvent = false) { - const ledList = document.getElementById('ledList'); - const ledControls = document.getElementById('ledControls'); - const deviceImageContainer = document.getElementById('deviceImageContainer'); - - let selectedLeds = Array.from(ledList.selectedOptions).map(option => option.value); - const cur = this.lightshow.vortex.engine().modes().curMode(); - if (!cur) { - ledList.innerHTML = ''; - return; - } - this.clearLedList(); - this.clearLedSelections(); - - if (!cur.isMultiLed()) { - for (let pos = 0; pos < this.lightshow.vortex.numLedsInMode(); ++pos) { - let ledName = this.lightshow.vortex.ledToString(pos) + " (" + this.lightshow.vortex.getPatternName(pos) + ")"; - const option = document.createElement('option'); - option.value = pos; - option.textContent = ledName; - ledList.appendChild(option); - } - ledControls.style.display = 'flex'; - deviceImageContainer.querySelectorAll('.led-indicator').forEach(indicator => { - indicator.style.backgroundColor = ''; // Reset to default - }); - if (selectedLeds.includes("multi")) { - this.selectAllLeds(); - selectedLeds = Array.from(ledList.selectedOptions).map(option => option.value); - } - } else { - // If multi-LED pattern is applied - let ledName = "Multi led (" + this.lightshow.vortex.getPatternName(this.lightshow.vortex.engine().leds().ledMulti()) + ")"; - const option = document.createElement('option'); - option.value = 'multi'; - option.textContent = ledName; - ledList.appendChild(option); - selectedLeds = [ "multi" ]; - - // Disable LED controls - ledControls.style.display = 'none'; - - // Set all LED indicators to green - deviceImageContainer.querySelectorAll('.led-indicator').forEach(indicator => { - indicator.classList.add('selected'); - }); - } - if (!selectedLeds.length && ledList.options.length > 0) { - selectedLeds = [ "0" ]; - } - this.applyLedSelections(selectedLeds); - } - - async renderLedIndicators(deviceName = null) { - const ledsFieldset = document.getElementById('ledsFieldset'); - const deviceImageContainer = document.getElementById('deviceImageContainer'); - const ledControls = document.getElementById('ledControls'); - - if (!deviceName || deviceName === 'None') { - ledsFieldset.style.display = 'none'; - return; - } - - ledsFieldset.style.display = 'block'; - deviceImageContainer.innerHTML = ''; - ledList.style.display = 'block'; - ledControls.style.display = 'flex'; - deviceImageContainer.innerHTML = ''; - - const overlay = document.createElement('div'); - overlay.classList.add('led-overlay'); - deviceImageContainer.appendChild(overlay); - - const deviceData = await this.getLedPositions(deviceName); - const deviceImageSrc = this.devices[deviceName].image; - - if (deviceImageSrc) { - const deviceImage = document.createElement('img'); - deviceImage.src = deviceImageSrc + '?v=' + new Date().getTime(); - deviceImage.style.display = 'block'; - deviceImage.style.width = '100%'; - deviceImage.style.height = 'auto'; - - deviceImage.onload = () => { - const scaleX = deviceImageContainer.clientWidth / deviceData.original_width; - const scaleY = deviceImageContainer.clientHeight / deviceData.original_height; - - deviceData.points.forEach((point, index) => { - const ledIndicator = document.createElement('div'); - ledIndicator.classList.add('led-indicator'); - ledIndicator.style.left = `${point.x * scaleX}px`; - ledIndicator.style.top = `${point.y * scaleY}px`; - ledIndicator.dataset.ledIndex = index; - - overlay.appendChild(ledIndicator); - }); - }; - - deviceImageContainer.appendChild(deviceImage); - } - } - - updateLedIndicators() { - const selectedLeds = this.getSelectedLeds(); - const cur = this.lightshow.vortex.engine().modes().curMode(); - - document.querySelectorAll('.led-indicator').forEach(indicator => { - const index = indicator.dataset.ledIndex; - if (cur && cur.isMultiLed()) { - indicator.classList.add('selected'); - } else { - if (selectedLeds.includes(index.toString())) { - indicator.classList.add('selected'); - } else { - indicator.classList.remove('selected'); - } - indicator.style.backgroundColor = ''; // Reset to default if not multi-LED - } - }); + refresh(fromEvent = false) { + this.refreshModeList(fromEvent); } refreshPatternControlPanel() { - document.dispatchEvent(new CustomEvent('modeChange', { detail: this.getSelectedLeds() })); - } - - clearLedList() { - const ledList = document.getElementById('ledList'); - ledList.innerHTML = ''; - } - - clearLedSelections() { - const ledList = document.getElementById('ledList'); - for (let option of ledList.options) { - option.selected = false; - } - } - - selectAllLeds() { - const ledList = document.getElementById('ledList'); - for (let option of ledList.options) { - option.selected = true; - } - this.handleLedSelectionChange(); - } - - applyLedSelections(selectedLeds) { - const ledList = document.getElementById('ledList'); - for (let option of ledList.options) { - if (selectedLeds.includes(option.value)) { - option.selected = true; - } else { - option.selected = false; - } - } - } - - selectAllLeds() { - const ledList = document.getElementById('ledList'); - for (let option of ledList.options) { - option.selected = true; - } - this.handleLedSelectionChange(); - } - - selectNoneLeds() { - const ledList = document.getElementById('ledList'); - for (let option of ledList.options) { - option.selected = false; - } - this.handleLedSelectionChange(); - } - - invertLeds() { - const ledList = document.getElementById('ledList'); - for (let option of ledList.options) { - option.selected = !option.selected; - } - this.handleLedSelectionChange(); - } - - evenLeds() { - const ledList = document.getElementById('ledList'); - for (let i = 0; i < ledList.options.length; i++) { - ledList.options[i].selected = (i % 2 === 0); - } - this.handleLedSelectionChange(); - } - - oddLeds() { - const ledList = document.getElementById('ledList'); - for (let i = 0; i < ledList.options.length; i++) { - ledList.options[i].selected = (i % 2 !== 0); - } - this.handleLedSelectionChange(); - } - - randomLeds(probability = 0.5) { - const ledList = document.getElementById('ledList'); - for (let option of ledList.options) { - option.selected = Math.random() < probability; - } - this.handleLedSelectionChange(); - } - - handleLedSelectionChange() { - const selectedOptions = this.getSelectedLeds(); - this.lightshow.targetLeds = selectedOptions; - document.dispatchEvent(new CustomEvent('ledsChange', { detail: selectedOptions })); - this.updateLedIndicators(); + document.dispatchEvent(new CustomEvent('modeChange', { detail: this.editor.ledSelectPanel.getSelectedLeds() })); } clearModeList() { @@ -839,7 +241,7 @@ export default class ModesPanel extends Panel { } this.lightshow.vortex.setCurMode(curSel, false); this.attachModeEventListeners(); - this.refreshLedList(fromEvent); + this.editor.ledSelectPanel.refreshLedList(fromEvent); } selectMode(index) { @@ -856,11 +258,10 @@ export default class ModesPanel extends Panel { selectedMode.style.display = 'flex'; } - this.refreshLedList(); + this.editor.ledSelectPanel.refreshLedList(); this.refreshPatternControlPanel(); } - attachModeEventListeners() { const modesListContainer = document.getElementById('modesListContainer'); @@ -917,36 +318,39 @@ export default class ModesPanel extends Panel { }); } - getLedList() { - const ledList = document.getElementById('ledList'); - return Array.from(ledList.options).map(option => option.value); - } - - getSelectedLeds() { - const ledList = document.getElementById('ledList'); - return Array.from(ledList.selectedOptions).map(option => option.value); - } - addMode() { let modeCount = this.lightshow.vortex.numModes(); - switch (this.selectedDevice) { - case 'Orbit': - case 'Handle': - case 'Gloves': - if (modeCount >= 14) { - Notification.failure("This device can only hold 14 modes"); - return; - } - break; - case 'Duo': - // TODO: version check? - if (modeCount >= 5) { - Notification.failure("This device can only hold 5 modes"); - return; - } - break; - default: - break; + let maxModes = 16; + const device = this.editor.devicePanel.selectedDevice; + switch (device) { + case 'Orbit': + case 'Handle': + case 'Gloves': + // these devices had 14 + maxModes = 14; + break; + case 'Duo': + // default duo max is 5 + maxModes = 5; + if (this.editor && this.editor.chromalinkPanel) { + const clPanel = this.editor.chromalinkPanel; + if (clPanel.duoHeader && clPanel.duoHeader.vMinor >= 4) { + // allow 9 modes after 1.4.x duo + maxModes = 9; + } + } + break; + case 'Chromadeck': + case 'Spark': + // 16 modes + break; + default: + break; + } + // check the mode count against max + if (modeCount >= maxModes) { + Notification.failure(`The ${device} can only hold ${maxModes} modes`); + return; } if (!this.lightshow.vortex.addNewMode(false)) { Notification.failure("Failed to add another mode"); @@ -1140,7 +544,7 @@ export default class ModesPanel extends Panel { if (addNew) { curSel = this.lightshow.vortex.engine().modes().curModeIndex(); let modeCount = this.lightshow.vortex.numModes(); - switch (this.selectedDevice) { + switch (this.editor.devicePanel.selectedDevice) { case 'Orbit': case 'Handle': case 'Gloves': diff --git a/js/PatternPanel.js b/js/PatternPanel.js index eff1728..b4e7622 100644 --- a/js/PatternPanel.js +++ b/js/PatternPanel.js @@ -30,7 +30,7 @@ export default class PatternPanel extends Panel { this.refresh(); document.addEventListener('modeChange', this.handleModeChange.bind(this)); document.addEventListener('ledsChange', this.handleLedsChange.bind(this)); - document.addEventListener('deviceConnected', this.handleDeviceConnected.bind(this)); + document.addEventListener('deviceChange', this.handleDeviceEvent.bind(this)); // Attach event listeners for help and randomize buttons document.getElementById('patternRandomizeButton').addEventListener('click', () => this.randomizePattern()); @@ -70,14 +70,41 @@ export default class PatternPanel extends Panel { this.refresh(true); } - handleDeviceConnected() { + handleDeviceEvent(deviceChangeEvent) { + // Access the custom data from `event.detail` + const { deviceEvent, deviceName } = deviceChangeEvent; + if (deviceEvent === 'waiting') { + this.onDeviceWaiting(deviceName); + } else if (deviceEvent === 'connect') { + this.onDeviceConnect(deviceName); + } else if (deviceEvent === 'disconnect') { + this.onDeviceDisconnect(deviceName); + } else if (deviceEvent === 'select') { + this.onDeviceSelected(deviceName); + } + } + + onDeviceWaiting(deviceName) { + // nothing yet + } + + onDeviceConnect(deviceName) { this.multiEnabled = true; this.populatePatternDropdown(); this.refresh(true); + // uh is this supposed to be here? this.vortexPort.startReading(); this.editor.demoModeOnDevice(); } + onDeviceDisconnect(deviceName) { + // nothing yet + } + + onDeviceSelected(deviceName) { + // nothing yet + } + setTargetSingles(selectedLeds = null) { const ledCount = this.lightshow.vortex.engine().leds().ledCount(); this.targetLeds = (selectedLeds || Array.from({ length: ledCount }, (_, i) => i.toString())) @@ -147,7 +174,7 @@ export default class PatternPanel extends Panel { dropdown.appendChild(blendGroup); dropdown.appendChild(solidGroup); - if (this.editor.modesPanel.selectedDevice !== 'None') { + if (this.editor.devicePanel.selectedDevice !== 'None') { dropdown.appendChild(multiGroup); } } diff --git a/js/UpdatePanel.js b/js/UpdatePanel.js index 7b4d4d6..301b488 100644 --- a/js/UpdatePanel.js +++ b/js/UpdatePanel.js @@ -2,7 +2,7 @@ import Panel from './Panel.js'; import Notification from './Notification.js'; export default class UpdatePanel extends Panel { - constructor(editor, modesPanel) { + constructor(editor) { const content = `
@@ -19,7 +19,6 @@ export default class UpdatePanel extends Panel { super('updatePanel', content, 'Device Updates', { showCloseButton: true }); this.editor = editor; this.vortexPort = editor.vortexPort; - this.modesPanel = modesPanel; this.espStub = null; } @@ -49,9 +48,9 @@ export default class UpdatePanel extends Panel { } }); - document.addEventListener('deviceConnected', () => { - Notification.success('Device connected. Ready to flash firmware.'); - }); + //document.addEventListener('deviceChange', () => { + // do anything if the device changes...? + //}); this.toggleCollapse(false); this.hide(); @@ -63,11 +62,10 @@ export default class UpdatePanel extends Panel { throw new Error('No serial port available.'); } - const esploaderMod = await window.esptoolPackage; - const esploader = new esploaderMod.ESPLoader(this.vortexPort.serialPort, console); - - await esploader.initialize(); - this.espStub = await esploader.runStub(); + const esptool = await window.esptoolPackage; + this.espLoader = new esptool.ESPLoader(this.vortexPort.serialPort, console); + await this.espLoader.initialize(); + this.espStub = await this.espLoader.runStub(); } catch (error) { throw new Error('Failed to initialize ESP flasher: ' + error.message); } @@ -80,7 +78,7 @@ export default class UpdatePanel extends Panel { async fetchAndFlashFirmware() { let targetDevice = this.vortexPort.name.toLowerCase(); if (!targetDevice) { - targetDevice = this.editor.modesPanel.selectedDevice.toLowerCase(); + targetDevice = this.editor.devicePanel.selectedDevice.toLowerCase(); } if (targetDevice === 'none') { throw new Error(`Select a device first`); @@ -222,9 +220,15 @@ export default class UpdatePanel extends Panel { console.log('All files flashed successfully.'); try { + console.log('ESP32 reset complete.'); + if (this.espLoader) { + //await this.espLoader.disconnect(); + await this.espLoader._reader.releaseLock(); + console.log('Disconnected ESP Loader.'); + } console.log('Resetting ESP32...'); await this.espStub.hardReset(); - console.log('ESP32 reset complete.'); + await this.editor.vortexPort.restartConnecton(); } catch (resetError) { console.error('Failed to reset ESP32:', resetError); } diff --git a/js/VortexEditor.js b/js/VortexEditor.js index 6406fe9..4976ae0 100644 --- a/js/VortexEditor.js +++ b/js/VortexEditor.js @@ -5,7 +5,9 @@ import AnimationPanel from './AnimationPanel.js'; import PatternPanel from './PatternPanel.js'; import ColorsetPanel from './ColorsetPanel.js'; import ColorPickerPanel from './ColorPickerPanel.js'; +import DevicePanel from './DevicePanel.js'; import ModesPanel from './ModesPanel.js'; +import LedSelectPanel from './LedSelectPanel.js'; import Modal from './Modal.js'; import VortexPort from './VortexPort.js'; import WelcomePanel from './WelcomePanel.js'; @@ -36,7 +38,9 @@ export default class VortexEditor { this.animationPanel = new AnimationPanel(this); this.patternPanel = new PatternPanel(this); this.colorsetPanel = new ColorsetPanel(this); + this.devicePanel = new DevicePanel(this); this.modesPanel = new ModesPanel(this); + this.ledSelectPanel = new LedSelectPanel(this); this.colorPickerPanel = new ColorPickerPanel(this); this.updatePanel = new UpdatePanel(this); this.chromalinkPanel = new ChromalinkPanel(this); @@ -47,11 +51,57 @@ export default class VortexEditor { this.animationPanel, this.patternPanel, this.colorsetPanel, + this.devicePanel, this.modesPanel, + this.ledSelectPanel, this.colorPickerPanel, this.updatePanel, this.chromalinkPanel ]; + this.devices = { + 'None': { + image: 'public/images/none-logo-square-512.png', + icon: 'public/images/none-logo-square-64.png', + label: 'None', + ledCount: 1 + }, + 'Orbit': { + image: 'public/images/orbit.png', + icon: 'public/images/orbit-logo-square-64.png', + label: 'Orbit', + ledCount: 28 + }, + 'Handle': { + image: 'public/images/handle.png', + icon: 'public/images/handle-logo-square-64.png', + label: 'Handle', + ledCount: 3 + }, + 'Gloves': { + image: 'public/images/gloves.png', + icon: 'public/images/gloves-logo-square-64.png', + label: 'Gloves', + ledCount: 10 + }, + 'Chromadeck': { + image: 'public/images/chromadeck.png', + icon: 'public/images/chromadeck-logo-square-64.png', + label: 'Chromadeck', + ledCount: 20 + }, + 'Spark': { + image: 'public/images/spark.png', + icon: 'public/images/spark-logo-square-64.png', + label: 'Spark', + ledCount: 6 + }, + 'Duo': { + image: 'public/images/duo.png', + icon: 'public/images/duo-logo-square-64.png', + label: 'Duo', + ledCount: 2 + } + }; } initialize() { @@ -73,7 +123,13 @@ export default class VortexEditor { // Keydown event to show updatePanel document.addEventListener('keydown', (event) => { if (event.key === 'Insert') { - this.checkVersion(this.vortexPort.name, this.vortexPort.version); + if (this.vortexPort.name.length > 0 && this.vortexPort.version.length > 0) { + this.checkVersion(this.vortexPort.name, this.vortexPort.version); + } else { + this.updatePanel.displayFirmwareUpdateInfo(this.devicePanel.selectedDevice, + '1.0.0', '1.0.1', 'https://vortex.community/downloads'); + Notification.error("Need a device connection to use update panel"); + } this.updatePanel.show(); } }); @@ -94,9 +150,11 @@ export default class VortexEditor { async checkVersion(device, version) { // the results are lowercased - if (!device.length || device === 'None') { - device = this.modesPanel.selectedDevice; - if (!device.length || device === 'None') { + if (!device.length) { + console.log("Missing device for comparison, checking devicePanel..."); + device = this.devicePanel.selectedDevice; + if (!device.length) { + console.log("Missing device for comparison, devicePanel and port device empty"); // not connected? return; } @@ -105,6 +163,7 @@ export default class VortexEditor { // this can happen if the update panel is forced open with Insert if (!version) { + console.log("Missing version for comparison, using 1.0.0..."); version = '1.0.0'; } @@ -120,11 +179,18 @@ export default class VortexEditor { } // Compare versions - if (latestFirmwareVersions && latestFirmwareVersions[lowerDevice]) { - const latestVersion = latestFirmwareVersions[lowerDevice].firmware.version; - const downloadUrl = latestFirmwareVersions[lowerDevice].firmware.fileUrl; - this.updatePanel.displayFirmwareUpdateInfo(device, version, latestVersion, downloadUrl); + if (!latestFirmwareVersions) { + console.log("Missing latest firmware version info"); + return; + } + if (!latestFirmwareVersions[lowerDevice]) { + console.log(`Missing device info for device '${lowerDevice}'`); + return; } + const latestVersion = latestFirmwareVersions[lowerDevice].firmware.version; + const downloadUrl = latestFirmwareVersions[lowerDevice].firmware.fileUrl; + console.log(`Comparing ${latestVersion} with ${downloadUrl} for ${device}...`); + this.updatePanel.displayFirmwareUpdateInfo(device, version, latestVersion, downloadUrl); } async pushToDevice() { diff --git a/js/VortexPort.js b/js/VortexPort.js index c214b89..0d96d4e 100644 --- a/js/VortexPort.js +++ b/js/VortexPort.js @@ -57,16 +57,19 @@ export default class VortexPort { } resetState() { - // Reset properties to default state - this.serialPort = null; + if (this.reader) { + try { + this.reader.releaseLock(); + } catch (error) { + console.warn('Error releasing reader in resetState:', error); + } finally { + this.reader = null; + } + } this.portActive = false; this.name = ''; this.version = 0; this.buildDate = ''; - if (this.reader) { - this.reader.releaseLock(); - this.reader = null; - } this.isTransmitting = false; // Reset the transmission state on reset this.hasUPDI = false; // Further state reset logic if necessary @@ -77,26 +80,32 @@ export default class VortexPort { } async requestDevice(callback) { + this.deviceCallback = callback; try { if (!this.serialPort) { this.serialPort = await navigator.serial.requestPort(); if (!this.serialPort) { throw new Error('Failed to open serial port'); } - await this.serialPort.open({ baudRate: 9600 }); - } - // is this necessary...? I don't remember why it's here - await this.serialPort.setSignals({ dataTerminalReady: true }); - this.portActive = false; - if (callback && typeof callback === 'function') { - callback('waiting'); + await this.serialPort.open({ baudRate: 115200 }); + // is this necessary...? I don't remember why it's here + await this.serialPort.setSignals({ dataTerminalReady: true }); + if (this.deviceCallback && typeof this.deviceCallback === 'function') { + this.deviceCallback('waiting'); + } } - this.listenForGreeting(callback); + await this.beginConnection(); } catch (error) { console.error('Error:', error); } } + async beginConnection(){ + console.log("Beginning connection..."); + this.portActive = false; + this.listenForGreeting(); + } + async writeData(data) { if (!this.serialPort || !this.serialPort.writable) { console.error('Port is not writable.'); @@ -127,23 +136,26 @@ export default class VortexPort { return true; } - listenForGreeting = async (callback) => { + listenForGreeting = async () => { while (!this.portActive && !this.cancelListeningForGreeting) { if (this.serialPort) { try { + console.log("Listening for greeting..."); // Read data from the serial port - const response = await this.readData(); + const response = await this.readData(true); if (!response) { console.log("Error: Connection broken"); // broken connection - break; + continue; } - let responseRegex = /^== Vortex Engine v(\d+\.\d+.\d+) '([\w\s]+)' \(built (.*)\) ==$/; + console.log("Matching: [" + response + "]..."); + + let responseRegex = /== Vortex Engine v(\d+\.\d+.\d+) '([\w\s]+)' \(built (.*)\) ==/; let match = response.match(responseRegex); if (!match) { // TODO: removeme later! backwards compatibility for old connection string - responseRegex = /^== Vortex Engine v(\d+\.\d+) '([\w\s]+)' \( built (.*)\) ==$/; + responseRegex = /== Vortex Engine v(\d+\.\d+) '([\w\s]+)' \( built (.*)\) ==/; match = response.match(responseRegex); } @@ -176,10 +188,10 @@ export default class VortexPort { this.portActive = true; this.serialPort.addEventListener("disconnect", (event) => { - this.disconnect(callback); + this.disconnect(); }); - if (callback && typeof callback === 'function') { - callback('connect'); + if (this.deviceCallback && typeof this.deviceCallback === 'function') { + this.deviceCallback('connect'); } } } catch (err) { @@ -191,15 +203,22 @@ export default class VortexPort { } } } - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise(resolve => setTimeout(resolve, 300)); } this.cancelListeningForGreeting = false; } - disconnect(callback = null) { - this.resetState(); // Reset the state of the class - if (callback && typeof callback === 'function') { - callback('disconnect'); + async restartConnecton() { + await this.beginConnection(); + } + + async disconnect() { + if (this.reader) { + await this.reader.cancel(); + } + this.resetState(); + if (this.deviceCallback && typeof this.deviceCallback === 'function') { + this.deviceCallback('disconnect'); } } @@ -211,10 +230,17 @@ export default class VortexPort { // todo: implement async read cancel } - async readData() { + async readData(fullResponse) { if (!this.serialPort || !this.serialPort.readable) { return null; } + if (this.accumulatedData.length > 0) { + // check the buffer first... + // Return any single byte + const singleByte = this.accumulatedData[0]; + this.accumulatedData = this.accumulatedData.substring(1); + return singleByte; + } if (this.reader) { try { this.reader.releaseLock(); @@ -238,15 +264,23 @@ export default class VortexPort { const text = new TextDecoder().decode(value); this.accumulatedData += text; - // If it starts with '=' or '==', look for the end delimiter '==' - if (this.accumulatedData.startsWith('=') || this.accumulatedData.startsWith('==')) { - const endIndex = this.accumulatedData.indexOf('==', 2); // Search for '==' after the first one. - - if (endIndex >= 0) { - const fullMessage = this.accumulatedData.substring(0, endIndex + 2).trim(); - this.accumulatedData = this.accumulatedData.substring(endIndex + 2); // Trim accumulatedData - return fullMessage; // Return the full message + if (fullResponse) { + const responseRegex = /==.*==/; + const match = this.accumulatedData.match(responseRegex); + if (match) { + const result = this.accumulatedData; + this.accumulatedData = ''; + return result; } + // If it starts with '=' or '==', look for the end delimiter '==' + //if (this.accumulatedData.startsWith('=') || this.accumulatedData.startsWith('==')) { + // const endIndex = this.accumulatedData.indexOf('==', 2); // Search for '==' after the first one. + + // if (endIndex >= 0) { + // const fullMessage = this.accumulatedData.substring(0, endIndex + 2).trim(); + // this.accumulatedData = this.accumulatedData.substring(endIndex + 2); // Trim accumulatedData + // return fullMessage; // Return the full message + // } } else { // Return any single byte const singleByte = this.accumulatedData[0]; @@ -564,7 +598,6 @@ export default class VortexPort { if (headerStream.size() > 5) { duoHeader.vBuild = headerData[5]; } - console.log(JSON.stringify(headerData)); // construct a full version string duoHeader.version = duoHeader.vMajor + '.' + duoHeader.vMinor + '.' + duoHeader.vBuild; duoHeader.rawData = headerData; @@ -770,6 +803,7 @@ export default class VortexPort { this.reader.releaseLock(); } this.reader = this.serialPort.readable.getReader(); + console.log("readFromSerialPort(): Got reader"); let result = null; try { result = await this.reader.read();