addons implemented
parent
432f28ba1e
commit
fd5a39db77
20
index.html
20
index.html
|
|
@ -37,6 +37,9 @@
|
||||||
<button id="btn-projects" title="Toggle projects panel">
|
<button id="btn-projects" title="Toggle projects panel">
|
||||||
<span class="icon">📂</span> Projects
|
<span class="icon">📂</span> Projects
|
||||||
</button>
|
</button>
|
||||||
|
<button id="btn-addons" title="Manage addons">
|
||||||
|
<span class="icon">🔌</span> Addons
|
||||||
|
</button>
|
||||||
<span id="connection-status" class="status-disconnected">Disconnected</span>
|
<span id="connection-status" class="status-disconnected">Disconnected</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -146,6 +149,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Addons manager overlay -->
|
||||||
|
<div id="addons-overlay" class="hidden">
|
||||||
|
<div id="addons-modal">
|
||||||
|
<div class="addons-header">
|
||||||
|
<h3>Addons</h3>
|
||||||
|
<button id="addons-close" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="addons-description">Upload <code>.js</code> addon files to add new toolbox categories. Addons are saved in your browser.</p>
|
||||||
|
<div class="addons-upload-row">
|
||||||
|
<input type="file" id="addon-file-input" accept=".js" />
|
||||||
|
<button id="addon-install-btn">Install</button>
|
||||||
|
</div>
|
||||||
|
<div id="addon-status" class="addons-status"></div>
|
||||||
|
<ul id="addons-list" class="addons-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Example addon: adds a "My Robot" toolbox category with two custom blocks.
|
||||||
|
//
|
||||||
|
// To use: click "Addons" in the toolbar, upload this file, and the
|
||||||
|
// "My Robot" category will appear in the toolbox.
|
||||||
|
|
||||||
|
// --- Block definitions ---
|
||||||
|
api.Blockly.Blocks['robot_drive'] = {
|
||||||
|
init() {
|
||||||
|
this.appendDummyInput()
|
||||||
|
.appendField('drive')
|
||||||
|
.appendField(new api.Blockly.FieldDropdown([
|
||||||
|
['forward', 'FORWARD'],
|
||||||
|
['backward', 'BACKWARD'],
|
||||||
|
['left', 'LEFT'],
|
||||||
|
['right', 'RIGHT'],
|
||||||
|
['stop', 'STOP'],
|
||||||
|
]), 'DIRECTION');
|
||||||
|
this.appendValueInput('SPEED')
|
||||||
|
.setCheck('Number')
|
||||||
|
.appendField('speed');
|
||||||
|
this.setInputsInline(true);
|
||||||
|
this.setPreviousStatement(true, null);
|
||||||
|
this.setNextStatement(true, null);
|
||||||
|
this.setColour(45);
|
||||||
|
this.setTooltip('Drive the robot in a direction at a given speed');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
api.Blockly.Blocks['robot_beep'] = {
|
||||||
|
init() {
|
||||||
|
this.appendValueInput('TIMES')
|
||||||
|
.setCheck('Number')
|
||||||
|
.appendField('beep');
|
||||||
|
this.appendDummyInput()
|
||||||
|
.appendField('times');
|
||||||
|
this.setInputsInline(true);
|
||||||
|
this.setPreviousStatement(true, null);
|
||||||
|
this.setNextStatement(true, null);
|
||||||
|
this.setColour(45);
|
||||||
|
this.setTooltip('Make the robot beep');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Code generators ---
|
||||||
|
api.pythonGenerator.forBlock['robot_drive'] = function (block) {
|
||||||
|
const dir = block.getFieldValue('DIRECTION');
|
||||||
|
const speed = api.pythonGenerator.valueToCode(
|
||||||
|
block, 'SPEED', api.pythonGenerator.ORDER_NONE
|
||||||
|
) || '0';
|
||||||
|
return `robot.drive("${dir.toLowerCase()}", ${speed})\n`;
|
||||||
|
};
|
||||||
|
|
||||||
|
api.pythonGenerator.forBlock['robot_beep'] = function (block) {
|
||||||
|
const times = api.pythonGenerator.valueToCode(
|
||||||
|
block, 'TIMES', api.pythonGenerator.ORDER_NONE
|
||||||
|
) || '1';
|
||||||
|
return `robot.beep(${times})\n`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Register toolbox category ---
|
||||||
|
api.registerCategories([
|
||||||
|
{
|
||||||
|
kind: 'category',
|
||||||
|
name: 'My Robot',
|
||||||
|
colour: '45',
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
kind: 'block',
|
||||||
|
type: 'robot_drive',
|
||||||
|
inputs: {
|
||||||
|
SPEED: { shadow: { type: 'math_number', fields: { NUM: 100 } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'block',
|
||||||
|
type: 'robot_beep',
|
||||||
|
inputs: {
|
||||||
|
TIMES: { shadow: { type: 'math_number', fields: { NUM: 3 } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
// h-bridge Motor Driver addon
|
||||||
|
//
|
||||||
|
// Adds a "h-bridge Motor" toolbox category with:
|
||||||
|
// - motor_init: set up a motor with IN1 and IN2 pins
|
||||||
|
// - motor_speed: set speed from -255 (full reverse) to +255 (full forward)
|
||||||
|
// - motor_stop: stop a motor
|
||||||
|
//
|
||||||
|
// Generates device-appropriate MicroPython:
|
||||||
|
// - ESP32 / RP2040: machine.Pin + machine.PWM
|
||||||
|
// - micro:bit: pinN.write_analog()
|
||||||
|
|
||||||
|
// --- Block definitions ---
|
||||||
|
|
||||||
|
api.Blockly.Blocks['hbridge_motor_init'] = {
|
||||||
|
init() {
|
||||||
|
this.appendDummyInput()
|
||||||
|
.appendField('init motor')
|
||||||
|
.appendField(new api.Blockly.FieldDropdown([
|
||||||
|
['A', 'A'],
|
||||||
|
['B', 'B'],
|
||||||
|
]), 'MOTOR')
|
||||||
|
.appendField('IN1 pin')
|
||||||
|
.appendField(new api.Blockly.FieldNumber(2, 0, 48, 1), 'IN1')
|
||||||
|
.appendField('IN2 pin')
|
||||||
|
.appendField(new api.Blockly.FieldNumber(3, 0, 48, 1), 'IN2');
|
||||||
|
this.setPreviousStatement(true, null);
|
||||||
|
this.setNextStatement(true, null);
|
||||||
|
this.setColour(30);
|
||||||
|
this.setTooltip('Initialize an h-bridge motor channel with two pins');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
api.Blockly.Blocks['hbridge_motor_speed'] = {
|
||||||
|
init() {
|
||||||
|
this.appendDummyInput()
|
||||||
|
.appendField('set motor')
|
||||||
|
.appendField(new api.Blockly.FieldDropdown([
|
||||||
|
['A', 'A'],
|
||||||
|
['B', 'B'],
|
||||||
|
]), 'MOTOR');
|
||||||
|
this.appendValueInput('SPEED')
|
||||||
|
.setCheck('Number')
|
||||||
|
.appendField('speed');
|
||||||
|
this.setInputsInline(true);
|
||||||
|
this.setPreviousStatement(true, null);
|
||||||
|
this.setNextStatement(true, null);
|
||||||
|
this.setColour(30);
|
||||||
|
this.setTooltip('Set motor speed: -255 (full reverse) to +255 (full forward)');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
api.Blockly.Blocks['hbridge_motor_stop'] = {
|
||||||
|
init() {
|
||||||
|
this.appendDummyInput()
|
||||||
|
.appendField('stop motor')
|
||||||
|
.appendField(new api.Blockly.FieldDropdown([
|
||||||
|
['A', 'A'],
|
||||||
|
['B', 'B'],
|
||||||
|
]), 'MOTOR');
|
||||||
|
this.setPreviousStatement(true, null);
|
||||||
|
this.setNextStatement(true, null);
|
||||||
|
this.setColour(30);
|
||||||
|
this.setTooltip('Stop an h-bridge motor (brake)');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Helpers used by generators ---
|
||||||
|
|
||||||
|
function isMicrobit() {
|
||||||
|
return api.getDeviceId() === 'microbit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mbPin(pin) {
|
||||||
|
var n = Math.max(0, Math.min(20, parseInt(pin, 10) || 0));
|
||||||
|
return 'pin' + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Code generators ---
|
||||||
|
|
||||||
|
api.pythonGenerator.forBlock['hbridge_motor_init'] = function (block) {
|
||||||
|
var motor = block.getFieldValue('MOTOR');
|
||||||
|
var in1 = block.getFieldValue('IN1');
|
||||||
|
var in2 = block.getFieldValue('IN2');
|
||||||
|
var m = motor.toLowerCase();
|
||||||
|
|
||||||
|
if (isMicrobit()) {
|
||||||
|
// micro:bit: no explicit init needed, just store pin numbers for later use.
|
||||||
|
// We define variables so speed/stop blocks can reference them.
|
||||||
|
api.pythonGenerator.definitions_['import_microbit'] = 'from microbit import *';
|
||||||
|
api.pythonGenerator.definitions_['hb_motor_' + m + '_pins'] =
|
||||||
|
'motor_' + m + '_in1 = ' + mbPin(in1) + '\n' +
|
||||||
|
'motor_' + m + '_in2 = ' + mbPin(in2);
|
||||||
|
api.pythonGenerator.definitions_['hb_set_motor_mb'] = [
|
||||||
|
'def _hb_set_motor(p1, p2, speed):',
|
||||||
|
' speed = max(-255, min(255, int(speed)))',
|
||||||
|
' duty = abs(speed) * 4',
|
||||||
|
' if speed > 0:',
|
||||||
|
' p1.write_analog(duty)',
|
||||||
|
' p2.write_analog(0)',
|
||||||
|
' elif speed < 0:',
|
||||||
|
' p1.write_analog(0)',
|
||||||
|
' p2.write_analog(duty)',
|
||||||
|
' else:',
|
||||||
|
' p1.write_analog(0)',
|
||||||
|
' p2.write_analog(0)',
|
||||||
|
].join('\n');
|
||||||
|
} else {
|
||||||
|
// ESP32 / RP2040: machine.PWM
|
||||||
|
api.pythonGenerator.definitions_['import_machine'] =
|
||||||
|
'from machine import Pin, PWM';
|
||||||
|
api.pythonGenerator.definitions_['hb_motor_' + m + '_in1'] =
|
||||||
|
'motor_' + m + '_in1 = PWM(Pin(' + in1 + '), freq=1000, duty=0)';
|
||||||
|
api.pythonGenerator.definitions_['hb_motor_' + m + '_in2'] =
|
||||||
|
'motor_' + m + '_in2 = PWM(Pin(' + in2 + '), freq=1000, duty=0)';
|
||||||
|
api.pythonGenerator.definitions_['hb_set_motor'] = [
|
||||||
|
'def _hb_set_motor(pwm1, pwm2, speed):',
|
||||||
|
' speed = max(-255, min(255, int(speed)))',
|
||||||
|
' duty = abs(speed) * 4',
|
||||||
|
' if speed > 0:',
|
||||||
|
' pwm1.duty(duty)',
|
||||||
|
' pwm2.duty(0)',
|
||||||
|
' elif speed < 0:',
|
||||||
|
' pwm1.duty(0)',
|
||||||
|
' pwm2.duty(duty)',
|
||||||
|
' else:',
|
||||||
|
' pwm1.duty(0)',
|
||||||
|
' pwm2.duty(0)',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
api.pythonGenerator.forBlock['hbridge_motor_speed'] = function (block) {
|
||||||
|
var motor = block.getFieldValue('MOTOR');
|
||||||
|
var m = motor.toLowerCase();
|
||||||
|
var speed = api.pythonGenerator.valueToCode(
|
||||||
|
block, 'SPEED', api.pythonGenerator.ORDER_NONE
|
||||||
|
) || '0';
|
||||||
|
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, ' + speed + ')\n';
|
||||||
|
};
|
||||||
|
|
||||||
|
api.pythonGenerator.forBlock['hbridge_motor_stop'] = function (block) {
|
||||||
|
var motor = block.getFieldValue('MOTOR');
|
||||||
|
var m = motor.toLowerCase();
|
||||||
|
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, 0)\n';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Register toolbox category ---
|
||||||
|
|
||||||
|
api.registerCategories([
|
||||||
|
{
|
||||||
|
kind: 'category',
|
||||||
|
name: 'H-Bridge Motor',
|
||||||
|
colour: '30',
|
||||||
|
contents: [
|
||||||
|
{ kind: 'block', type: 'hbridge_motor_init' },
|
||||||
|
{
|
||||||
|
kind: 'block',
|
||||||
|
type: 'hbridge_motor_speed',
|
||||||
|
inputs: {
|
||||||
|
SPEED: { shadow: { type: 'math_number', fields: { NUM: 200 } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'block', type: 'hbridge_motor_stop' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import * as Blockly from 'blockly';
|
||||||
|
import { pythonGenerator } from 'blockly/python';
|
||||||
|
import {
|
||||||
|
registerAddonCategories,
|
||||||
|
clearAddonCategories,
|
||||||
|
getAddonCategories,
|
||||||
|
getDeviceId,
|
||||||
|
} from '../devices/registry.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'esp32block_addons';
|
||||||
|
|
||||||
|
/** { name: string, source: string }[] — persisted addon scripts */
|
||||||
|
function loadSavedAddons() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAddonList(list) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstalledAddons() {
|
||||||
|
return loadSavedAddons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API object passed to every addon's init().
|
||||||
|
* refreshToolbox is injected later by setRefreshCallback().
|
||||||
|
*/
|
||||||
|
let refreshToolboxFn = () => {};
|
||||||
|
|
||||||
|
export function setRefreshCallback(fn) {
|
||||||
|
refreshToolboxFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAddonApi() {
|
||||||
|
return {
|
||||||
|
Blockly,
|
||||||
|
pythonGenerator,
|
||||||
|
getDeviceId,
|
||||||
|
registerCategories(categories) {
|
||||||
|
registerAddonCategories(categories);
|
||||||
|
refreshToolboxFn();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an addon script string in the current page.
|
||||||
|
* The script should be a self-contained function body or ES module-style code
|
||||||
|
* that calls `init(api)` where api is the addon API.
|
||||||
|
*
|
||||||
|
* We wrap the source in a function that receives the api object.
|
||||||
|
*/
|
||||||
|
function runAddonSource(source) {
|
||||||
|
const fn = new Function('api', source);
|
||||||
|
fn(makeAddonApi());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a single addon from its source string. */
|
||||||
|
export function loadAddonFromSource(name, source) {
|
||||||
|
runAddonSource(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an addon from a File object, persist it, and run it. */
|
||||||
|
export async function installAddonFromFile(file) {
|
||||||
|
const source = await file.text();
|
||||||
|
const name = file.name.replace(/\.js$/i, '');
|
||||||
|
|
||||||
|
const list = loadSavedAddons();
|
||||||
|
const existing = list.findIndex(a => a.name === name);
|
||||||
|
if (existing >= 0) list.splice(existing, 1);
|
||||||
|
list.push({ name, source });
|
||||||
|
saveAddonList(list);
|
||||||
|
|
||||||
|
loadAddonFromSource(name, source);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an addon by name. Requires page reload to fully take effect. */
|
||||||
|
export function removeAddon(name) {
|
||||||
|
const list = loadSavedAddons().filter(a => a.name !== name);
|
||||||
|
saveAddonList(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run all persisted addons (call once at startup). */
|
||||||
|
export function loadAllSavedAddons() {
|
||||||
|
clearAddonCategories();
|
||||||
|
const list = loadSavedAddons();
|
||||||
|
for (const addon of list) {
|
||||||
|
try {
|
||||||
|
loadAddonFromSource(addon.name, addon.source);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[addon] Failed to load "${addon.name}":`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,16 +60,33 @@ export function canFlashInBrowser() {
|
||||||
|
|
||||||
// --- toolbox builder ---
|
// --- toolbox builder ---
|
||||||
|
|
||||||
|
const addonCategories = [];
|
||||||
|
|
||||||
|
export function registerAddonCategories(categories) {
|
||||||
|
for (const cat of categories) {
|
||||||
|
if (cat && cat.kind === 'category') addonCategories.push(cat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAddonCategories() {
|
||||||
|
return addonCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAddonCategories() {
|
||||||
|
addonCategories.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildToolbox(deviceId) {
|
export function buildToolbox(deviceId) {
|
||||||
const profile = devices[deviceId || currentId];
|
const profile = devices[deviceId || currentId];
|
||||||
if (!profile) return { kind: 'categoryToolbox', contents: [] };
|
if (!profile) return { kind: 'categoryToolbox', contents: [] };
|
||||||
|
|
||||||
return {
|
const contents = [...profile.categories];
|
||||||
kind: 'categoryToolbox',
|
if (addonCategories.length) {
|
||||||
contents: [
|
contents.push({ kind: 'sep' });
|
||||||
...profile.categories,
|
contents.push(...addonCategories);
|
||||||
{ kind: 'sep' },
|
}
|
||||||
...builtinCategories,
|
contents.push({ kind: 'sep' });
|
||||||
],
|
contents.push(...builtinCategories);
|
||||||
};
|
|
||||||
|
return { kind: 'categoryToolbox', contents };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
src/main.js
94
src/main.js
|
|
@ -9,6 +9,13 @@ import {
|
||||||
canFlashInBrowser,
|
canFlashInBrowser,
|
||||||
buildToolbox,
|
buildToolbox,
|
||||||
} from './devices/registry.js';
|
} from './devices/registry.js';
|
||||||
|
import {
|
||||||
|
setRefreshCallback,
|
||||||
|
loadAllSavedAddons,
|
||||||
|
installAddonFromFile,
|
||||||
|
removeAddon,
|
||||||
|
getInstalledAddons,
|
||||||
|
} from './addons/loader.js';
|
||||||
import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js';
|
import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js';
|
||||||
import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js';
|
import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js';
|
||||||
import { flashFirmware } from './serial/flasher.js';
|
import { flashFirmware } from './serial/flasher.js';
|
||||||
|
|
@ -19,6 +26,9 @@ import './style.css';
|
||||||
|
|
||||||
// ─── Blockly Workspace ───────────────────────────────────
|
// ─── Blockly Workspace ───────────────────────────────────
|
||||||
|
|
||||||
|
// Load saved addons before building toolbox so their categories are included
|
||||||
|
loadAllSavedAddons();
|
||||||
|
|
||||||
const workspace = Blockly.inject('blockly-div', {
|
const workspace = Blockly.inject('blockly-div', {
|
||||||
toolbox: buildToolbox(getDeviceId()),
|
toolbox: buildToolbox(getDeviceId()),
|
||||||
theme: Blockly.Themes.Dark,
|
theme: Blockly.Themes.Dark,
|
||||||
|
|
@ -28,6 +38,11 @@ const workspace = Blockly.inject('blockly-div', {
|
||||||
renderer: 'zelos',
|
renderer: 'zelos',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Now that workspace exists, set the refresh callback for addons
|
||||||
|
setRefreshCallback(() => {
|
||||||
|
workspace.updateToolbox(buildToolbox(getDeviceId()));
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Live Code Preview ───────────────────────────────────
|
// ─── Live Code Preview ───────────────────────────────────
|
||||||
|
|
||||||
const codeOutput = document.getElementById('code-output');
|
const codeOutput = document.getElementById('code-output');
|
||||||
|
|
@ -132,7 +147,7 @@ function hideSendProgress() {
|
||||||
deviceSelect.value = getDeviceId();
|
deviceSelect.value = getDeviceId();
|
||||||
deviceSelect.addEventListener('change', () => {
|
deviceSelect.addEventListener('change', () => {
|
||||||
setDeviceId(deviceSelect.value);
|
setDeviceId(deviceSelect.value);
|
||||||
workspace.updateToolbox(buildToolbox(getDeviceId()));
|
refreshToolbox();
|
||||||
updateCodePreview();
|
updateCodePreview();
|
||||||
// Update Flash button tooltip/label based on device
|
// Update Flash button tooltip/label based on device
|
||||||
btnFlash.title = canFlashInBrowser()
|
btnFlash.title = canFlashInBrowser()
|
||||||
|
|
@ -143,6 +158,10 @@ btnFlash.title = canFlashInBrowser()
|
||||||
? 'Flash MicroPython firmware'
|
? 'Flash MicroPython firmware'
|
||||||
: 'Download firmware (drag to device)';
|
: 'Download firmware (drag to device)';
|
||||||
|
|
||||||
|
function refreshToolbox() {
|
||||||
|
workspace.updateToolbox(buildToolbox(getDeviceId()));
|
||||||
|
}
|
||||||
|
|
||||||
function setConnectedUI(connected) {
|
function setConnectedUI(connected) {
|
||||||
btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect';
|
btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect';
|
||||||
btnRun.disabled = !connected;
|
btnRun.disabled = !connected;
|
||||||
|
|
@ -386,3 +405,76 @@ terminalInput.addEventListener('keydown', async (e) => {
|
||||||
await writeString(text + '\r\n');
|
await writeString(text + '\r\n');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Addons Manager UI ──────────────────────────────────
|
||||||
|
|
||||||
|
const btnAddons = document.getElementById('btn-addons');
|
||||||
|
const addonsOverlay = document.getElementById('addons-overlay');
|
||||||
|
const addonsClose = document.getElementById('addons-close');
|
||||||
|
const addonFileInput = document.getElementById('addon-file-input');
|
||||||
|
const addonInstallBtn = document.getElementById('addon-install-btn');
|
||||||
|
const addonStatus = document.getElementById('addon-status');
|
||||||
|
const addonsList = document.getElementById('addons-list');
|
||||||
|
|
||||||
|
function renderAddonsList() {
|
||||||
|
const addons = getInstalledAddons();
|
||||||
|
addonsList.innerHTML = '';
|
||||||
|
if (!addons.length) {
|
||||||
|
addonsList.innerHTML = '<li class="addons-empty">No addons installed</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const addon of addons) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'addons-item';
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'addons-item-name';
|
||||||
|
nameSpan.textContent = addon.name;
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'addons-item-remove';
|
||||||
|
removeBtn.textContent = 'Remove';
|
||||||
|
removeBtn.title = 'Remove addon (reload required)';
|
||||||
|
removeBtn.addEventListener('click', () => {
|
||||||
|
removeAddon(addon.name);
|
||||||
|
renderAddonsList();
|
||||||
|
addonStatus.textContent = `"${addon.name}" removed. Reload the page to apply.`;
|
||||||
|
addonStatus.className = 'addons-status status-warn';
|
||||||
|
});
|
||||||
|
li.appendChild(nameSpan);
|
||||||
|
li.appendChild(removeBtn);
|
||||||
|
addonsList.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnAddons.addEventListener('click', () => {
|
||||||
|
addonsOverlay.classList.remove('hidden');
|
||||||
|
renderAddonsList();
|
||||||
|
addonStatus.textContent = '';
|
||||||
|
addonStatus.className = 'addons-status';
|
||||||
|
});
|
||||||
|
|
||||||
|
addonsClose.addEventListener('click', () => {
|
||||||
|
addonsOverlay.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
addonsOverlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === addonsOverlay) addonsOverlay.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
addonInstallBtn.addEventListener('click', async () => {
|
||||||
|
const file = addonFileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
addonStatus.textContent = 'Select a .js file first.';
|
||||||
|
addonStatus.className = 'addons-status status-err';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const name = await installAddonFromFile(file);
|
||||||
|
addonStatus.textContent = `"${name}" installed successfully!`;
|
||||||
|
addonStatus.className = 'addons-status status-ok';
|
||||||
|
renderAddonsList();
|
||||||
|
addonFileInput.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
addonStatus.textContent = `Error: ${err.message}`;
|
||||||
|
addonStatus.className = 'addons-status status-err';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
174
src/style.css
174
src/style.css
|
|
@ -82,6 +82,24 @@ html, body {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-icon-btn {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.toolbar-icon-btn:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-actions {
|
.toolbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -658,3 +676,159 @@ html, body {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Addons Manager Overlay --- */
|
||||||
|
#addons-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#addons-modal {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
width: 440px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-header button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.addons-header button:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-description code {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-upload-row input[type="file"] {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-upload-row button {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-upload-row button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-toolbar);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-status {
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.addons-status.status-ok { color: var(--green); }
|
||||||
|
.addons-status.status-err { color: var(--red); }
|
||||||
|
.addons-status.status-warn { color: var(--yellow); }
|
||||||
|
|
||||||
|
.addons-list {
|
||||||
|
list-style: none;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-list .addons-empty {
|
||||||
|
padding: 12px 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-list .addons-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-list .addons-item:hover {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-item-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-item-remove {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addons-item-remove:hover {
|
||||||
|
background: var(--red);
|
||||||
|
color: var(--bg-toolbar);
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue