diff --git a/index.html b/index.html index 425e67b..07cb5a8 100644 --- a/index.html +++ b/index.html @@ -40,6 +40,9 @@ + Disconnected @@ -51,6 +54,34 @@
+ + +
diff --git a/src/devices/registry.js b/src/devices/registry.js index 8fc1569..9583dc1 100644 --- a/src/devices/registry.js +++ b/src/devices/registry.js @@ -76,8 +76,95 @@ export function clearAddonCategories() { addonCategories.length = 0; } -export function buildToolbox(deviceId) { +/** + * Returns the full unfiltered category list for a device (for the customizer UI). + */ +export function getFullCategories(deviceId) { const profile = devices[deviceId || currentId]; + if (!profile) return []; + const contents = [...profile.categories]; + if (addonCategories.length) { + contents.push(...addonCategories); + } + contents.push(...builtinCategories); + return contents.filter(c => c.kind === 'category'); +} + +// --- toolbox filter (show/hide categories & blocks) --- + +const FILTER_KEY = 'esp32block_toolbox_filter'; +const PRESETS_KEY = 'esp32block_toolbox_presets'; + +let _customizeMode = false; + +export function setCustomizeMode(on) { _customizeMode = on; } +export function isCustomizeMode() { return _customizeMode; } + +export function getFilter(deviceId) { + try { + const data = JSON.parse(localStorage.getItem(FILTER_KEY)) || {}; + return data[deviceId || currentId] || {}; + } catch { return {}; } +} + +export function setFilter(deviceId, filter) { + let data; + try { data = JSON.parse(localStorage.getItem(FILTER_KEY)) || {}; } + catch { data = {}; } + data[deviceId || currentId] = filter; + localStorage.setItem(FILTER_KEY, JSON.stringify(data)); +} + +// --- presets --- + +export function getSavedPresets() { + try { return JSON.parse(localStorage.getItem(PRESETS_KEY)) || []; } + catch { return []; } +} + +export function savePreset(name, filter) { + const presets = getSavedPresets(); + const idx = presets.findIndex(p => p.name === name); + if (idx >= 0) presets[idx] = { name, filter }; + else presets.push({ name, filter }); + localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); +} + +export function deletePreset(name) { + const presets = getSavedPresets().filter(p => p.name !== name); + localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); +} + +// --- filter application --- + +function applyFilter(categories, deviceId) { + const filter = getFilter(deviceId); + const hidCats = new Set(filter.hiddenCategories || []); + const hidBlocks = new Set(filter.hiddenBlocks || []); + if (!hidCats.size && !hidBlocks.size) return categories; + + const result = []; + for (const item of categories) { + if (item.kind === 'sep') { result.push(item); continue; } + if (item.kind === 'category' && hidCats.has(item.name)) continue; + if (item.kind === 'category' && item.contents) { + const filtered = item.contents.filter(b => !hidBlocks.has(b.type)); + if (filtered.length === 0) continue; + result.push({ ...item, contents: filtered }); + } else { + result.push(item); + } + } + return result.filter((item, i, arr) => { + if (item.kind !== 'sep') return true; + if (i === 0 || i === arr.length - 1) return false; + return arr[i - 1]?.kind !== 'sep'; + }); +} + +export function buildToolbox(deviceId) { + const id = deviceId || currentId; + const profile = devices[id]; if (!profile) return { kind: 'categoryToolbox', contents: [] }; const contents = [...profile.categories]; @@ -88,5 +175,6 @@ export function buildToolbox(deviceId) { contents.push({ kind: 'sep' }); contents.push(...builtinCategories); - return { kind: 'categoryToolbox', contents }; + if (_customizeMode) return { kind: 'categoryToolbox', contents }; + return { kind: 'categoryToolbox', contents: applyFilter(contents, id) }; } diff --git a/src/main.js b/src/main.js index 6669661..b3f4761 100644 --- a/src/main.js +++ b/src/main.js @@ -23,6 +23,7 @@ import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './s import { flashFirmware } from './serial/flasher.js'; import { appendToTerminal, clearTerminal } from './ui/terminal.js'; import { initResizablePanels, initPanelToggles, initProjectTabs, setProjectsPanelCallbacks } from './ui/panels.js'; +import { initToolboxCustomizer, toggleCustomizeMode, refreshCustomizer } from './ui/toolboxCustomizer.js'; import { initProjectsDialog, refreshAll as refreshProjects, refreshDeviceList } from './ui/projectsDialog.js'; import './style.css'; @@ -62,6 +63,10 @@ function rebuildDeviceSelect() { setDeviceListRefreshCallback(rebuildDeviceSelect); +// Toolbox customizer (show/hide categories & blocks) +initToolboxCustomizer(refreshToolbox); +document.getElementById('btn-customize').addEventListener('click', toggleCustomizeMode); + // ─── Live Code Preview ─────────────────────────────────── const codeOutput = document.getElementById('code-output'); @@ -167,8 +172,8 @@ deviceSelect.value = getDeviceId(); deviceSelect.addEventListener('change', () => { setDeviceId(deviceSelect.value); refreshToolbox(); + refreshCustomizer(); updateCodePreview(); - // Update Flash button tooltip/label based on device btnFlash.title = canFlashInBrowser() ? 'Flash MicroPython firmware' : 'Download firmware (drag to device)'; diff --git a/src/style.css b/src/style.css index 5a886a0..8c11f07 100644 --- a/src/style.css +++ b/src/style.css @@ -832,3 +832,188 @@ html, body { color: var(--bg-toolbar); border-color: var(--red); } + +/* --- Toolbox Customizer Sidebar Panel --- */ +#customizer-panel { + width: 280px; + min-width: 0; + border-left: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +#customizer-panel.hidden { + display: none !important; +} + +#customizer-panel .panel-body { + overflow-y: auto; +} + +.cust-done-btn { + background: var(--accent); + color: var(--bg-toolbar); + border: none; + border-radius: var(--radius); + padding: 2px 12px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.4px; + transition: opacity 0.15s; +} +.cust-done-btn:hover { opacity: 0.85; } + +/* Preset controls */ +.cust-presets { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.cust-preset-row { + display: flex; + gap: 4px; +} + +.cust-preset-row select, +.cust-preset-row input { + flex: 1; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px 6px; + font-size: 11px; + outline: none; + min-width: 0; +} + +.cust-preset-row select:focus, +.cust-preset-row input:focus { + border-color: var(--accent); +} + +.cust-preset-row button { + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px 8px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, border-color 0.15s; +} + +.cust-preset-row button:hover { + background: var(--accent); + color: var(--bg-toolbar); + border-color: var(--accent); +} + +.cust-preset-row button.btn-danger:hover { + background: var(--red); + border-color: var(--red); +} + +/* Category/block tree */ +.customizer-tree { + overflow-y: auto; + padding: 4px 6px; + flex: 1; +} + +.cust-cat { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 4px; + user-select: none; + transition: opacity 0.15s; +} + +.cust-cat:hover { background: var(--bg-surface); } + +.cust-swatch { + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; +} + +.cust-cat-label { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cust-expand { + background: none; + border: none; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 1px 4px; + border-radius: 3px; + line-height: 1; + flex-shrink: 0; +} +.cust-expand:hover { + color: var(--text-primary); + background: var(--bg-primary); +} + +.cust-cb { + accent-color: var(--accent); + width: 14px; + height: 14px; + cursor: pointer; + flex-shrink: 0; +} + +.cust-blocks { + padding-left: 30px; +} + +.cust-block-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 6px; + border-radius: 4px; + transition: opacity 0.15s; +} + +.cust-block-row:hover { background: var(--bg-surface); } + +.cust-block-label { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cust-dimmed { opacity: 0.4; } + +.cust-cb:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Active customize button highlight */ +#btn-customize.active { + background: var(--accent); + color: var(--bg-toolbar); + border-color: var(--accent); +} diff --git a/src/ui/toolboxCustomizer.js b/src/ui/toolboxCustomizer.js new file mode 100644 index 0000000..b6dcd5b --- /dev/null +++ b/src/ui/toolboxCustomizer.js @@ -0,0 +1,259 @@ +import { + getDeviceId, + getFullCategories, + getFilter, + setFilter, + setCustomizeMode, + isCustomizeMode, + getSavedPresets, + savePreset, + deletePreset, +} from '../devices/registry.js'; + +let refreshToolboxFn = () => {}; +let panelEl, treeEl, presetSelect, presetNameInput, btnDone; + +export function initToolboxCustomizer(refreshCb) { + refreshToolboxFn = refreshCb; + panelEl = document.getElementById('customizer-panel'); + treeEl = document.getElementById('customizer-tree'); + presetSelect = document.getElementById('preset-select'); + presetNameInput = document.getElementById('preset-name'); + btnDone = document.getElementById('customizer-done'); + + btnDone.addEventListener('click', exitCustomizeMode); + document.getElementById('preset-load').addEventListener('click', loadSelectedPreset); + document.getElementById('preset-save').addEventListener('click', saveCurrentAsPreset); + document.getElementById('preset-delete').addEventListener('click', deleteSelectedPreset); + document.getElementById('preset-show-all').addEventListener('click', showAll); + document.getElementById('preset-hide-all').addEventListener('click', hideAll); +} + +export function toggleCustomizeMode() { + if (isCustomizeMode()) exitCustomizeMode(); + else enterCustomizeMode(); +} + +export function refreshCustomizer() { + if (isCustomizeMode()) { + renderTree(); + renderPresetDropdown(); + } +} + +function enterCustomizeMode() { + setCustomizeMode(true); + refreshToolboxFn(); + renderTree(); + renderPresetDropdown(); + panelEl.classList.remove('hidden'); + document.getElementById('btn-customize').classList.add('active'); + window.dispatchEvent(new Event('resize')); +} + +function exitCustomizeMode() { + setCustomizeMode(false); + panelEl.classList.add('hidden'); + document.getElementById('btn-customize').classList.remove('active'); + refreshToolboxFn(); + window.dispatchEvent(new Event('resize')); +} + +// ─── Filter helpers ────────────────────────────────────── + +function currentFilter() { + const f = getFilter(getDeviceId()); + return { + hiddenCategories: new Set(f.hiddenCategories || []), + hiddenBlocks: new Set(f.hiddenBlocks || []), + }; +} + +function saveFilter(hiddenCategories, hiddenBlocks) { + setFilter(getDeviceId(), { + hiddenCategories: [...hiddenCategories], + hiddenBlocks: [...hiddenBlocks], + }); +} + +// ─── Preset actions ────────────────────────────────────── + +function renderPresetDropdown() { + const presets = getSavedPresets(); + presetSelect.innerHTML = ''; + for (const p of presets) { + const opt = document.createElement('option'); + opt.value = p.name; + opt.textContent = p.name; + presetSelect.appendChild(opt); + } +} + +function loadSelectedPreset() { + const name = presetSelect.value; + if (!name) return; + const presets = getSavedPresets(); + const preset = presets.find(p => p.name === name); + if (!preset) return; + setFilter(getDeviceId(), preset.filter); + renderTree(); +} + +function saveCurrentAsPreset() { + const name = presetNameInput.value.trim(); + if (!name) { presetNameInput.focus(); return; } + const f = getFilter(getDeviceId()); + savePreset(name, f); + presetNameInput.value = ''; + renderPresetDropdown(); + presetSelect.value = name; +} + +function deleteSelectedPreset() { + const name = presetSelect.value; + if (!name) return; + deletePreset(name); + renderPresetDropdown(); +} + +function showAll() { + saveFilter(new Set(), new Set()); + renderTree(); +} + +function hideAll() { + const cats = getFullCategories(getDeviceId()); + const hiddenCats = new Set(); + const hiddenBlocks = new Set(); + for (const cat of cats) { + hiddenCats.add(cat.name); + if (cat.contents) { + for (const b of cat.contents) { + if (b.type) hiddenBlocks.add(b.type); + } + } + } + saveFilter(hiddenCats, hiddenBlocks); + renderTree(); +} + +// ─── Tree rendering ────────────────────────────────────── + +function renderTree() { + const cats = getFullCategories(getDeviceId()); + const { hiddenCategories, hiddenBlocks } = currentFilter(); + treeEl.innerHTML = ''; + + for (const cat of cats) { + const catHidden = hiddenCategories.has(cat.name); + + // Category row + const row = document.createElement('div'); + row.className = 'cust-cat' + (catHidden ? ' cust-dimmed' : ''); + + const catCb = document.createElement('input'); + catCb.type = 'checkbox'; + catCb.checked = !catHidden; + catCb.className = 'cust-cb'; + + const swatch = document.createElement('span'); + swatch.className = 'cust-swatch'; + if (cat.colour) { + swatch.style.background = `hsl(${cat.colour}, 60%, 50%)`; + } else { + swatch.style.background = 'var(--text-muted)'; + } + + const label = document.createElement('span'); + label.className = 'cust-cat-label'; + label.textContent = cat.name; + + const hasBlocks = cat.contents && cat.contents.length > 0; + + const toggle = document.createElement('button'); + toggle.className = 'cust-expand'; + toggle.innerHTML = '▸'; + toggle.title = 'Expand blocks'; + + row.appendChild(catCb); + row.appendChild(swatch); + row.appendChild(label); + if (hasBlocks) row.appendChild(toggle); + treeEl.appendChild(row); + + // Block list (collapsed by default) + const blockList = document.createElement('div'); + blockList.className = 'cust-blocks hidden'; + + if (hasBlocks) { + for (const block of cat.contents) { + if (!block.type) continue; + + const bRow = document.createElement('div'); + const bHidden = catHidden || hiddenBlocks.has(block.type); + bRow.className = 'cust-block-row' + (bHidden ? ' cust-dimmed' : ''); + + const bCb = document.createElement('input'); + bCb.type = 'checkbox'; + bCb.checked = !catHidden && !hiddenBlocks.has(block.type); + bCb.disabled = catHidden; + bCb.className = 'cust-cb'; + + const bLabel = document.createElement('span'); + bLabel.className = 'cust-block-label'; + bLabel.textContent = block.type.replace(/_/g, ' '); + + bRow.appendChild(bCb); + bRow.appendChild(bLabel); + blockList.appendChild(bRow); + + bCb.addEventListener('change', () => { + const f = currentFilter(); + if (bCb.checked) { + f.hiddenBlocks.delete(block.type); + } else { + f.hiddenBlocks.add(block.type); + } + saveFilter(f.hiddenCategories, f.hiddenBlocks); + bRow.classList.toggle('cust-dimmed', !bCb.checked); + }); + } + } + treeEl.appendChild(blockList); + + // Expand toggle + if (hasBlocks) { + toggle.addEventListener('click', () => { + blockList.classList.toggle('hidden'); + toggle.innerHTML = blockList.classList.contains('hidden') ? '▸' : '▾'; + }); + } + + // Category checkbox + catCb.addEventListener('change', () => { + const f = currentFilter(); + if (catCb.checked) { + f.hiddenCategories.delete(cat.name); + } else { + f.hiddenCategories.add(cat.name); + } + saveFilter(f.hiddenCategories, f.hiddenBlocks); + row.classList.toggle('cust-dimmed', !catCb.checked); + + const childCbs = blockList.querySelectorAll('.cust-cb'); + childCbs.forEach(cb => { + cb.disabled = !catCb.checked; + if (!catCb.checked) { + cb.checked = false; + cb.closest('.cust-block-row')?.classList.add('cust-dimmed'); + } else { + const type = cb.closest('.cust-block-row') + ?.querySelector('.cust-block-label')?.textContent.replace(/ /g, '_'); + const isHidden = f.hiddenBlocks.has(type); + cb.checked = !isHidden; + cb.closest('.cust-block-row')?.classList.toggle('cust-dimmed', isHidden); + } + }); + }); + } +}