added runtime toolbox customization (show/hide)

main
Jake 2026-02-25 10:07:26 +08:00
parent f2196f0c2a
commit 7d19311098
5 changed files with 571 additions and 3 deletions

View File

@ -40,6 +40,9 @@
<button id="btn-addons" title="Manage addons">
<span class="icon">&#128268;</span> Addons
</button>
<button id="btn-customize" title="Show/hide toolbox categories and blocks">
<span class="icon">&#9881;</span> Customize
</button>
<span id="connection-status" class="status-disconnected">Disconnected</span>
</div>
</header>
@ -51,6 +54,34 @@
<div id="blockly-div"></div>
</div>
<!-- Customize toolbox sidebar -->
<div id="customizer-panel" class="ide-panel hidden">
<div class="panel-header">
<span class="panel-title">Customize Toolbox</span>
<button id="customizer-done" class="cust-done-btn" title="Exit customize mode">Done</button>
</div>
<div class="panel-body">
<div class="cust-presets">
<div class="cust-preset-row">
<select id="preset-select" title="Saved presets">
<option value="">— select preset —</option>
</select>
<button id="preset-load" title="Load selected preset">Load</button>
<button id="preset-delete" class="btn-danger" title="Delete selected preset">Del</button>
</div>
<div class="cust-preset-row">
<input type="text" id="preset-name" placeholder="Preset name..." />
<button id="preset-save" title="Save current settings as preset">Save</button>
</div>
<div class="cust-preset-row">
<button id="preset-show-all" title="Show everything">Show All</button>
<button id="preset-hide-all" title="Hide everything">Hide All</button>
</div>
</div>
<div id="customizer-tree" class="customizer-tree"></div>
</div>
</div>
<!-- Projects right sidebar -->
<div id="projects-panel" class="ide-panel">
<div class="panel-header">

View File

@ -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) };
}

View File

@ -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)';

View File

@ -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);
}

259
src/ui/toolboxCustomizer.js Normal file
View File

@ -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 = '<option value="">— select preset —</option>';
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 = '&#9656;';
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') ? '&#9656;' : '&#9662;';
});
}
// 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);
}
});
});
}
}