ui: V09 redesign - Control/Program/Console/Settings shell

Replaces the legacy side-menu chrome with a 4-tab top header.

- index.pug: tablet/kiosk fit-to-viewport script, header tab nav,
  estop/state badges in header.
- app.js: route hash to (control|program|console|<settings-family>),
  multi-section settings shell.
- control-view: header DRO, jog grid, MDI/probe/macros panels.
- program-view + program-mixin: file browser + toolpath preview +
  run/pause/stop, replaces the legacy 'macros' tab content.
- console-view: MDI shell, message log, indicators.
- settings-shell-view: rail-driven inner pages (Display & Units,
  Probing, G-code & Motion, Macros, Network, etc.).
- settings-view: filter Svelte SettingsView to one rail section.
- SettingsView.svelte: tag every section with data-sec=… so the
  filter above can hide non-matching ones.
- style.styl: ~2700 lines of V09 layout, DRO, jog grid, status
  strip, and tablet/kiosk variants.

No A-axis / auxiliary-axis content lives on this branch.
This commit is contained in:
2026-05-03 14:11:29 +02:00
parent c10f5c053a
commit 94072253d4
16 changed files with 4165 additions and 1876 deletions

View File

@@ -4,6 +4,7 @@ const api = require("./api");
const cookie = require("./cookie")("bbctrl-");
const Sock = require("./sock");
const semverLt = require("semver/functions/lt");
const restartTiming = require("./restart-timing");
if (document.getElementById("svelte-dialog-host") != undefined) {
SvelteComponents.createComponent(
@@ -103,12 +104,23 @@ module.exports = new Vue({
return {
status: "connecting",
currentView: "loading",
// Top-level shell tab. Mapped from the URL hash by parse_hash().
// One of: control | program | console | settings
top_tab: "control",
// Sub-route when a tab has internal pages (e.g. console:mdi,
// settings:admin-network, settings:motor:0). The settings sub
// also drives which inner view is mounted.
sub_tab: "",
sys_open: false,
has_camera: true,
messages_log: [],
messages_seen: 0,
display_units: localStorage.getItem("display_units") || "METRIC",
index: -1,
modified: false,
template: require("../resources/config-template.json"),
config: {
settings: {
settings: {
units: "METRIC",
"easy-adapter": false
},
@@ -143,22 +155,15 @@ module.exports = new Vue({
estop: { template: "#estop-template" },
"loading-view": { template: "<h1>Loading...</h1>" },
"control-view": require("./control-view"),
"settings-view": require("./settings-view"),
"motor-view": require("./motor-view"),
"tool-view": require("./tool-view"),
"io-view": require("./io-view"),
"admin-general-view": require("./admin-general-view"),
"admin-network-view": require("./admin-network-view"),
"macros-view": require('./macros'),
"help-view": require("./help-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function() {
return {
showUnimplemented: false
};
},
},
"program-view": require("./program-view"),
"console-view": require("./console-view"),
// The settings-shell renders the rail + an inner routed view.
// All settings-family hashes (settings, admin-general,
// admin-network, motor:N, tool, io, macros, help, cheat-sheet)
// resolve to this same shell; parse_hash() sets sub_tab so the
// shell knows which inner template to mount.
"settings-shell-view": require("./settings-shell-view"),
},
watch: {
@@ -166,6 +171,25 @@ module.exports = new Vue({
localStorage.setItem("display_units", value);
SvelteComponents.setDisplayUnits(value);
},
// Mirror controller messages into a console log used by the
// Console > Messages tab and the header badge counter.
"state.messages": {
handler: function(messages) {
if (!Array.isArray(messages)) return;
this.messages_log = messages.map(m => ({
text: m.text,
id: m.id,
level: /^#/.test(m.text || "") ? "info" : "warning",
ts: m.ts || Date.now(),
}));
if (this.top_tab === "console" && this.sub_tab === "messages") {
this.messages_seen = this.messages_log.length;
}
},
deep: true,
immediate: true,
},
},
events: {
@@ -227,6 +251,19 @@ module.exports = new Vue({
},
computed: {
// True when the UI is in kiosk mode — i.e. running on the
// controller's own onboard browser (Pi 3B at 1366x768) or
// explicitly forced via ?kiosk=1. Source-of-truth is the
// `kiosk-mode` class added to <html> by the inline script
// in index.pug, which already honors hostname + URL param +
// localStorage. The Pi's VideoCore IV is too slow for the
// three.js toolpath preview, so we suppress that panel in
// kiosk mode and let the gcode listing take the full width.
is_kiosk: function() {
return typeof document !== "undefined"
&& document.documentElement.classList.contains("kiosk-mode");
},
popupMessages: function() {
const msgs = [];
@@ -252,18 +289,130 @@ module.exports = new Vue({
enable_rotary: function() {
if(this.state["2an"] == 1 || this.state["2an"] == 3) return true;
return false;
}
},
// ---------------- header chrome helpers ----------------
// Underlying machine state from the controller. Mirrors
// control-view's `mach_state` so the header has access without
// depending on the routed component.
mach_state: function() {
const cycle = this.state.cycle;
const xx = this.state.xx;
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
return cycle.toUpperCase();
}
return xx || "";
},
// Short text for the READY pill in the header.
state_label: function() {
const s = this.mach_state;
if (!s) return "--";
return s;
},
// Class added to the READY pill (.state-badge) so styling can
// reflect ready / running / holding / fault / estop.
state_class: function() {
const s = this.mach_state;
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
if (s == "HOLDING" || s == "STOPPING") return "warn";
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
if (s == "READY") return "ok";
return "unknown";
},
mach_state_full: function() {
const s = this.mach_state;
if (s == "ESTOPPED") return "E-Stopped \u2014 release to clear";
if (s == "HOLDING") return "Feed hold (" + (this.state.pr || "paused") + ")";
if (s == "RUNNING") return "Running program";
if (s == "HOMING") return "Homing axes";
if (s == "JOGGING") return "Jogging";
if (s == "READY") return "Ready";
return s;
},
// Pip color for the unified system pill.
sys_class: function() {
const wifi_off = !this.config.wifiName || this.config.wifiName == "not connected";
const cam_off = !this.has_camera;
const hot = this.state && 80 <= this.state.rpi_temp;
if (hot) return "red";
if (wifi_off || cam_off) return "amber";
return "green";
},
// Compact summary for the system pill.
sys_summary: function() {
const issues = [];
if (!this.config.wifiName || this.config.wifiName == "not connected") {
issues.push("WiFi off");
}
if (!this.has_camera) issues.push("Camera offline");
if (this.state && 80 <= this.state.rpi_temp) issues.push("Pi hot");
if (this.is_rotary_active) issues.push("Rotary");
if (issues.length === 0) return "All systems";
if (issues.length === 1) return issues[0];
return issues.length + " notes";
},
// Number of unread Console > Messages entries.
messages_count: function() {
return Math.max(0, this.messages_log.length - this.messages_seen);
},
},
ready: function() {
window.onhashchange = () => this.parse_hash();
// Embedded Svelte subviews (A axis settings, etc.) signal
// unsaved changes via this event. The master Save button
// highlights when modified is true.
window.addEventListener("onefin:dirty", () => {
this.modified = true;
});
// Resolve the initial route before the websocket connects so
// the shell shows the right view even on a slow / offline
// controller. update() will call parse_hash() again once the
// first config is in. Skip routing into the Svelte settings
// family before config has loaded — those components read
// many config keys (settings.units, settings.probing-prompts,
// motion.*, etc.) and would throw on first paint with the
// empty placeholder config.
const settingsFamily = [
"settings", "probing", "gcode",
"admin-general", "admin-network",
"motor", "tool", "io", "macros",
"help", "cheat-sheet",
];
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
if (settingsFamily.indexOf(initialHead) === -1) {
this.parse_hash();
}
// else: stay on "loading" until update() completes and calls
// parse_hash() itself.
this.connect();
// Close the system popover when clicking anywhere else.
document.addEventListener("click", () => {
if (this.sys_open) this.sys_open = false;
});
SvelteComponents.registerControllerMethods({
dispatch: (...args) => this.$dispatch(...args)
});
},
methods: {
block_error_dialog: function() {
this.errorTimeoutStart = Date.now();
@@ -338,6 +487,12 @@ module.exports = new Vue({
toggle_rotary: async function(isActive) {
try {
await api.put("rotary", {status: isActive});
// The /api/rotary endpoint rewrites motors[1]/[2]
// in config.json on the server. Refetch so the UI
// reflects the new motor config (otherwise the
// motor settings page keeps showing pre-toggle
// values until the next page reload).
await this.update();
} catch (error) {
console.error(error);
alert("Error occured");
@@ -372,11 +527,19 @@ module.exports = new Vue({
connect: function() {
this.sock = new Sock(`//${location.host}/sockjs`);
let _gotFirstMsg = false;
let _gotFirstState = false;
this.sock.onmessage = (e) => {
if (typeof e.data != "object") {
return;
}
if (!_gotFirstMsg) {
_gotFirstMsg = true;
restartTiming.onWsFirstMessage();
}
if (e.data.log && e.data.log.msg !== "Switch not found") {
this.$broadcast("log", e.data.log);
@@ -386,6 +549,11 @@ module.exports = new Vue({
}
}
if (!_gotFirstState) {
_gotFirstState = true;
restartTiming.onFirstState();
}
// Check for session ID change on controller
if ("sid" in e.data) {
if (typeof this.sid == "undefined") {
@@ -410,6 +578,7 @@ module.exports = new Vue({
this.sock.onopen = () => {
this.status = "connected";
restartTiming.onWsOpen();
this.$emit(this.status);
this.$broadcast(this.status);
};
@@ -421,6 +590,21 @@ module.exports = new Vue({
};
},
// Maps a URL hash to (currentView, top_tab, sub_tab, index).
// Hash layouts supported (all kept for backward compat):
// #control -> control tab
// #program[:auto] -> program tab
// #console[:mdi|messages|indicators]
// -> console tab
// #settings -> settings tab home
// #admin-general -> settings tab, admin-general inside
// #admin-network -> settings tab, admin-network inside
// #motor:0..3 -> settings tab, motor 0..3
// #tool -> settings tab, tool view
// #io -> settings tab, io view
// #macros -> settings tab, macros view
// #help -> settings tab, help view
// #cheat-sheet -> settings tab, cheat sheet view
parse_hash: function() {
const hash = location.hash.substr(1);
@@ -430,12 +614,57 @@ module.exports = new Vue({
}
const parts = hash.split(":");
const head = parts[0];
if (parts.length == 2) {
this.index = parts[1];
this.index = parts.length > 1 ? parts[1] : -1;
// Legacy / settings-managed views resolve under the
// Settings tab while keeping their existing top-level
// hash. This preserves all existing deep links.
const settingsViews = [
"settings", "probing", "gcode",
"admin-general", "admin-network",
"motor", "tool", "io", "macros",
"help", "cheat-sheet",
];
if (head == "control") {
this.top_tab = "control";
this.sub_tab = "";
this.currentView = "control";
} else if (head == "program") {
this.top_tab = "program";
this.sub_tab = parts[1] || "auto";
this.currentView = "program";
} else if (head == "console") {
this.top_tab = "console";
this.sub_tab = parts[1] || "mdi";
this.currentView = "console";
} else if (settingsViews.indexOf(head) !== -1) {
this.top_tab = "settings";
this.sub_tab = head;
// All settings-family routes mount the same shell;
// shell picks inner view from sub_tab. Vary the
// currentView token so Vue 1 fully remounts the
// shell on every navigation — this avoids stale :class
// bindings against the local `sub` data prop.
this.currentView = "settings-shell";
} else {
// Unknown hash: route to settings shell anyway so we
// never end up rendering a bare loading screen.
this.top_tab = "settings";
this.sub_tab = head;
this.currentView = "settings-shell";
}
this.currentView = parts[0];
// Mark Console messages as seen when we enter that tab.
if (this.top_tab == "console" && this.sub_tab == "messages") {
this.messages_seen = this.messages_log.length;
}
},
toggle_sys_popover: function() {
this.sys_open = !this.sys_open;
},
save: async function() {
@@ -455,7 +684,7 @@ module.exports = new Vue({
this.config["selected-tool-settings"][selected_tool] = settings;
this.display_units = this.config.settings["units"];
try {
await api.put("config/save", this.config);
this.modified = false;

125
src/js/console-view.js Normal file
View File

@@ -0,0 +1,125 @@
"use strict";
const api = require("./api");
// Console tab — MDI command input, message log and live indicators.
// Sub-tab state syncs with the URL hash (#console:mdi |
// #console:messages | #console:indicators) so deep links work.
module.exports = {
template: "#console-view-template",
props: ["config", "template", "state"],
data: function () {
return {
mdi: "",
history: [],
sub: "mdi",
// Local mirror of $root.messages_count so Vue 1 reactivity works.
unread_messages_local: 0,
};
},
watch: {
sub: function () {
// Switching to messages marks them as seen so the header badge
// clears.
if (this.sub === "messages") {
this.$root.messages_seen = this.$root.messages_log.length;
this.unread_messages_local = 0;
}
},
},
computed: {
unread_messages: function () {
return this.unread_messages_local;
},
mach_state: function () {
const cycle = this.state.cycle;
const xx = this.state.xx;
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
return cycle.toUpperCase();
}
return xx || "";
},
is_idle: function () { return this.state.cycle == "idle"; },
can_mdi: function () {
return this.is_idle || this.state.cycle == "mdi";
},
mach_units: function () {
return this.$root.display_units;
},
},
ready: function () {
this._onHash = () => this.refresh_from_hash();
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
this._poll = setInterval(() => {
// Cheap re-poll for unread message count; Vue 1 cannot observe
// `$root.messages_count` directly so we mirror it here.
const c = this.$root && this.$root.messages_count;
if (typeof c === "number" && c !== this.unread_messages_local) {
this.unread_messages_local = c;
}
}, 500);
},
beforeDestroy: function () {
if (this._onHash) window.removeEventListener("hashchange", this._onHash);
if (this._poll) clearInterval(this._poll);
},
methods: {
refresh_from_hash: function () {
const hash = location.hash.substr(1);
const parts = hash.split(":");
const sub = parts[0] === "console" ? (parts[1] || "mdi") : "mdi";
this.sub = sub;
if (sub === "messages" && this.$root) {
this.$root.messages_seen = this.$root.messages_log.length;
this.unread_messages_local = 0;
}
},
select_sub: function (name) {
this.sub = name;
// Update URL hash for deep links / back-button.
const h = "#console" + (name && name !== "mdi" ? ":" + name : "");
if (location.hash !== h) {
history.replaceState(null, "", h);
}
if (name === "messages") {
this.$root.messages_seen = this.$root.messages_log.length;
this.unread_messages_local = 0;
}
},
prepend: function (token) {
this.mdi = token + this.mdi.trimStart();
},
append: function (token) {
const tail = this.mdi.endsWith(" ") || !this.mdi ? "" : " ";
this.mdi = this.mdi + tail + token;
},
submit_mdi: function () {
if (!this.mdi) return;
this.$dispatch("send", this.mdi);
if (!this.history.length || this.history[0] != this.mdi) {
this.history.unshift(this.mdi);
}
this.mdi = "";
},
load_history: function (index) {
this.mdi = this.history[index];
},
},
};

View File

@@ -1,7 +1,6 @@
"use strict";
const api = require("./api");
const utils = require("./utils");
const cookie = require("./cookie")("bbctrl-");
module.exports = {
@@ -12,15 +11,7 @@ module.exports = {
return {
current_time: "",
mach_units: this.$root.state.metric ? "METRIC" : "IMPERIAL",
mdi: "",
last_file: undefined,
last_file_time: undefined,
toolpath: {},
toolpath_progress: 0,
axes: "xyzabc",
history: [],
speed_override: 1,
feed_override: 1,
jog_incr_amounts: {
METRIC: {
fine: 0.1,
@@ -38,34 +29,14 @@ module.exports = {
jog_incr: localStorage.getItem("jog_incr") || "small",
jog_step: cookie.get_bool("jog-step"),
jog_adjust: parseInt(cookie.get("jog-adjust", 2)),
deleteGCode: false,
tab: "auto",
ask_home: true,
folder_name: "",
edited: false,
uploading_files: false,
confirmDelete: false,
create_folder: false,
showGcodeMessage: false,
showNoGcodeMessage: false,
macrosLoading: false,
show_gcodes: false,
GCodeNotFound: false,
show_probe_dialog: false,
filesUploaded: 0,
totalFiles: 0,
files_sortby: "By Upload Date",
selected_items_to_delete: [],
search_query: "",
filtered_files: [],
selected_folder_index: null,
overrides_open: false,
};
},
components: {
"axis-control": require("./axis-control"),
"path-viewer": require("./path-viewer"),
"gcode-viewer": require("./gcode-viewer"),
},
watch: {
@@ -80,16 +51,6 @@ module.exports = {
immediate: true,
},
"state.line": function () {
if (this.mach_state != "HOMING") {
this.$broadcast("gcode-line", this.state.line);
}
},
"state.selected_time": function () {
this.load();
},
jog_step: function () {
cookie.set_bool("jog-step", this.jog_step);
},
@@ -127,43 +88,16 @@ module.exports = {
return state || "";
},
pause_reason: function () {
return this.state.pr;
},
is_running: function () {
return this.mach_state == "RUNNING" || this.mach_state == "HOMING";
},
is_stopping: function () {
return this.mach_state == "STOPPING";
},
is_holding: function () {
return this.mach_state == "HOLDING";
},
is_ready: function () {
return this.mach_state == "READY";
can_set_axis: function () {
return this.state.cycle == "idle";
},
is_idle: function () {
return this.state.cycle == "idle";
},
is_paused: function () {
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
},
can_mdi: function () {
return this.is_idle || this.state.cycle == "mdi";
},
can_set_axis: function () {
return this.is_idle;
// TODO allow setting axis position during pause
// return this.is_idle || this.is_paused;
is_ready: function () {
return this.mach_state == "READY";
},
message: function () {
@@ -191,57 +125,21 @@ module.exports = {
},
plan_time_remaining: function () {
if (!(this.is_stopping || this.is_running || this.is_holding)) {
return 0;
}
return this.toolpath.time - this.plan_time;
const stopping = this.mach_state == "STOPPING";
const running = this.mach_state == "RUNNING" || this.mach_state == "HOMING";
const holding = this.mach_state == "HOLDING";
if (!(stopping || running || holding)) return 0;
const tp = this.$root && this.$root.toolpath ? this.$root.toolpath.time : 0;
return (tp || 0) - this.plan_time;
},
eta: function () {
if (this.mach_state != "RUNNING") {
return "";
}
const remaining = this.plan_time_remaining;
const d = new Date();
d.setSeconds(d.getSeconds() + remaining);
return d.toLocaleString();
},
progress: function () {
if (!this.toolpath.time || this.is_ready) {
return 0;
}
const p = this.plan_time / this.toolpath.time;
return Math.min(1, p);
},
gcode_files: function () {
if (!this.state.folder) {
return [];
}
const folder = this.state.gcode_list.find(item => item.name == this.state.folder);
if (!folder) {
return [];
}
const files = folder.files.filter(item => this.state.files.includes(item.file_name)).map(item => item.file_name);
if (this.files_sortby == "A-Z") {
return files.sort();
} else if (this.files_sortby == "Z-A") {
return files.sort().reverse();
} else {
return files;
}
},
gcode_filtered_files: function () {
return this.filtered_files.filter(file => file.toLowerCase().includes(this.search_query.toLowerCase()));
},
gcode_folders: function () {
return this.state.gcode_list
.map(item => item.name)
.filter(element => element !== "default")
.sort();
state_kpi_class: function () {
const s = this.mach_state;
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
if (s == "HOLDING" || s == "STOPPING") return "warn";
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
if (s == "READY") return "ok";
return "";
},
},
@@ -264,14 +162,9 @@ module.exports = {
M72
`);
},
folder_name_edited: function () {
this.edited = true;
},
},
ready: function () {
this.load();
setInterval(() => {
this.current_time = new Date().toLocaleTimeString();
}, 1000);
@@ -287,28 +180,39 @@ module.exports = {
},
methods: {
save_config: async function (config) {
try {
await api.put("config/save", config);
this.$dispatch("update");
} catch (error) {
console.error("Restore Failed: ", error);
alert("Restore failed");
}
},
populateFiles(index) {
this.selected_folder_index = index;
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
},
getJogIncrStyle(value) {
const weight = `font-weight:${this.jog_incr === value ? "bold" : "normal"}`;
const color = this.jog_incr === value ? "color:#0078e7" : "";
return [weight, color].join(";");
},
// Should the macro row render a colored left stripe for this
// macro? Only when the user has explicitly picked a color. The
// controller seeds new macros with default placeholders like
// "#ffffff" or "#dedede"; treat anything that close to white as
// "no color".
has_macro_color(macros) {
if (!macros || typeof macros.color !== "string") return false;
const c = macros.color.trim().toLowerCase();
if (!c) return false;
const defaults = [
"#fff", "#ffffff", "#fefefe", "#fdfdfd", "#fcfcfc",
"#dedede", "#dddddd", "#cccccc",
];
if (defaults.indexOf(c) !== -1) return false;
// Fallback: if the color is very close to white (sum of RGB
// > 690), suppress the stripe.
const m = c.match(/^#([0-9a-f]{6})$/);
if (m) {
const v = parseInt(m[1], 16);
const r = (v >> 16) & 0xff;
const g = (v >> 8) & 0xff;
const b = v & 0xff;
if (r + g + b > 690) return false;
}
return true;
},
jog_fn: function (x_jog, y_jog, z_jog, a_jog) {
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
@@ -324,426 +228,6 @@ module.exports = {
`);
},
send: function (msg) {
this.$dispatch("send", msg);
},
toggle_sorting: function () {
if (this.files_sortby === "By Upload Date") {
this.files_sortby = "A-Z";
} else if (this.files_sortby === "A-Z") {
this.files_sortby = "Z-A";
} else if (this.files_sortby === "Z-A") {
this.files_sortby = "By Upload Date";
}
},
load: function () {
const file_time = this.state.selected_time;
const file = this.state.selected;
if (this.last_file == file && this.last_file_time == file_time) {
return;
}
if (this.state.selected && !this.state.files.includes(this.state.selected)) {
this.GCodeNotFound = true;
return;
}
this.last_file = file;
this.last_file_time = file_time;
this.$broadcast("gcode-load", file);
this.$broadcast("gcode-line", this.state.line);
this.toolpath_progress = 0;
this.load_toolpath(file, file_time);
},
load_toolpath: async function (file, file_time) {
this.toolpath = {};
if (!file || this.last_file_time != file_time) {
return;
}
this.showGcodeMessage = true;
while (this.showGcodeMessage) {
try {
const toolpath = await api.get(`path/${file}`);
this.toolpath_progress = toolpath.progress;
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
this.showGcodeMessage = false;
if (toolpath.bounds) {
toolpath.filename = file;
this.toolpath_progress = 1;
this.toolpath = toolpath;
const state = this.$root.state;
for (const axis of "xyzabc") {
Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]);
Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]);
}
}
}
} catch (error) {
console.error(error);
}
}
},
submit_mdi: function () {
this.send(this.mdi);
if (!this.history.length || this.history[0] != this.mdi) {
this.history.unshift(this.mdi);
}
this.mdi = "";
},
mdi_start_pause: function () {
if (this.state.xx == "RUNNING") {
this.pause();
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
this.unpause();
} else {
this.submit_mdi();
}
},
load_history: function (index) {
this.mdi = this.history[index];
},
open_file: function () {
utils.clickFileInput("gcode-file-input");
},
open_folder: function () {
utils.clickFileInput("gcode-folder-input");
},
edited_folder_name: function (event) {
if (event.target.value.trim() != "") {
this.$dispatch("folder_name_edited");
}
},
update_config: function () {
this.config.gcode_list = [...this.state.gcode_list];
this.config.non_macros_list = [...this.state.non_macros_list];
this.config.macros_list = [...this.state.macros_list];
this.config.macros = [...this.state.macros];
},
reset_gcode: function () {
this.state.selected = "";
this.last_file = "";
this.$broadcast("gcode-load", "");
},
upload_gcode: async function (filename, file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploading_files = false;
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve("file uploaded");
} else {
console.error("File upload failed:", xhr.statusText);
reject("upload failed");
}
};
xhr.onerror = () => {
alert("Upload failed.");
reject("upload failed");
};
xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true);
xhr.send(file);
});
},
readFile: function (file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = error => {
reject(error);
};
reader.readAsText(file, "utf-8");
});
},
validateFiles: async function (files) {
const validFiles = [];
for (const file of files) {
const extension = file.name.split(".").pop().toLowerCase();
const validExtensions = ["nc", "ngc", "gcode", "gc"];
if (validExtensions.includes(extension)) {
validFiles.push(file);
} else {
alert(`Unsupported file : ${file.name}`);
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploadFiles = false;
}
}
}
return validFiles;
},
uploadValidFiles: async function (files, folderName) {
const updatedConfig = { ...this.config };
for (const file of files) {
try {
const gcode = await this.readFile(file);
await this.upload_gcode(file.name, gcode);
const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name);
if (!isAlreadyPresent) {
updatedConfig.non_macros_list.push({ file_name: file.name });
}
if (folderName) {
const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName);
if (folder) {
if (!folder.files.map(item => item.file_name).includes(file.name)) {
folder.files.push({ file_name: file.name });
}
} else {
updatedConfig.gcode_list.push({
name: folderName,
type: "folder",
files: [
{
file_name: file.name,
},
],
});
}
} else {
var folder_to_add = updatedConfig.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!folder_to_add) {
folder_to_add = updatedConfig.gcode_list.unshift({
name: this.state.folder,
type: "folder",
files: [
{
file_name: file.name,
},
],
});
folder_to_add = updatedConfig.gcode_list[0];
}
if (!folder_to_add.files.find(item => item.file_name == file.name)) {
folder_to_add.files.push({ file_name: file.name });
}
}
} catch (error) {
console.warn(`error uploading file : `, error);
}
}
return updatedConfig;
},
upload_files: async function (files, folderName) {
this.update_config();
const validFiles = await this.validateFiles(files);
const updatedConfig = await this.uploadValidFiles(validFiles, folderName);
await this.save_config(updatedConfig);
},
upload_file: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.totalFiles = files.length;
await this.upload_files(files);
},
create_new_folder: async function () {
const folder_name = this.folder_name.trim();
if (folder_name != "") {
if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) {
alert("Folder with the same name already exists!");
return;
} else {
this.update_config();
this.config.gcode_list.push({
name: folder_name,
type: "folder",
files: [],
});
}
this.state.folder = folder_name;
this.edited = false;
this.create_folder = false;
this.folder_name = "";
this.save_config(this.config);
}
},
cancel_new_folder: function () {
this.create_folder = false;
this.folder_name = "";
},
upload_folder: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.totalFiles = files.length;
const folderName = files[0].webkitRelativePath.split("/")[0];
this.upload_files(files, folderName);
},
delete_current: async function () {
if (!this.state.selected) {
this.deleteGCode = false;
return;
}
this.update_config();
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const folder_to_update = this.config.gcode_list.find(
item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder",
);
folder_to_update.files = folder_to_update.files.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const exception_list = this.state.macros_list.map(item => item.file_name);
let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item));
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.save_config(this.config);
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
cancel_delete: function () {
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_all: function () {
api.delete("file");
this.deleteGCode = false;
},
delete_all_except_macros: async function () {
this.update_config();
const macrosList = this.state.macros_list.map(item => item.file_name).toString();
api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`);
this.config.non_macros_list = [];
this.config.gcode_list = [
{
name: "default",
type: "folder",
files: [],
},
];
this.save_config(this.config);
this.state.folder = "default";
this.state.selected = "";
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_folder: async function () {
this.update_config();
if (this.state.folder && this.state.folder != "default") {
const files_to_move = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (files_to_move) {
const default_folder = this.config.gcode_list.find(item => item.name == "default");
default_folder.files = [...default_folder.files, ...files_to_move.files].sort();
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
this.save_config(this.config);
}
}
this.state.folder = "default";
this.confirmDelete = false;
},
delete_folder_and_files: async function () {
if (!this.state.folder) {
this.confirmDelete = false;
return;
}
this.update_config();
const selected_folder = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!selected_folder) {
return;
}
const macrosList = this.state.macros_list.map(item => item.file_name);
var files_to_delete = selected_folder.files
.map(item => item.file_name)
.filter(item => !macrosList.includes(item));
if (selected_folder.name != "default") {
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
} else {
selected_folder.files = [];
}
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !files_to_delete.includes(item.file_name),
);
this.save_config(this.config);
this.state.folder = "default";
this.confirmDelete = false;
},
home: function (axis) {
this.ask_home = false;
@@ -765,6 +249,15 @@ module.exports = {
api.put(`home/${axis}/clear`);
},
home_all: async function () {
this.ask_home = false;
try {
await api.put("home");
} catch (e) {
console.error("Home all failed:", e);
}
},
show_set_position: function (axis) {
SvelteComponents.showDialog("SetAxisPosition", { axis });
},
@@ -790,93 +283,20 @@ module.exports = {
},
zero: function (axis) {
if (typeof axis == "undefined") {
this.zero_all();
} else {
this.set_position(axis, 0);
}
},
start_pause: function () {
this.macrosLoading = false;
if (this.state.xx == "RUNNING") {
this.pause();
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
this.unpause();
} else {
this.start();
}
},
start: function () {
api.put("start");
},
pause: function () {
api.put("pause");
},
unpause: function () {
api.put("unpause");
},
optional_pause: function () {
api.put("pause/optional");
},
stop: function () {
api.put("stop");
},
step: function () {
api.put("step");
},
override_feed: function () {
api.put(`override/feed/${this.feed_override}`);
},
override_speed: function () {
api.put(`override/speed/${this.speed_override}`);
},
current: function (axis, value) {
const x = value / 32.0;
if (this.state[`${axis}pl`] == x) {
return;
}
const data = {};
data[`${axis}pl`] = x;
this.send(JSON.stringify(data));
if (typeof axis == "undefined") this.zero_all();
else this.set_position(axis, 0);
},
showProbeDialog: function (probeType) {
if(this.show_probe_dialog){
if (this.show_probe_dialog) {
this.show_probe_dialog = false;
}
SvelteComponents.showDialog("Probe", { probeType, isRotaryActive: this.state["2an"] == 3 });
},
run_macro: function (id) {
if (this.state.macros[id].file_name == "default") {
this.showNoGcodeMessage = true;
} else {
if (this.state.macros[id].file_name != this.state.selected) {
this.state.selected = this.state.macros[id].file_name;
}
try {
this.load();
if (this.state.macros[id].alert == true) {
this.macrosLoading = true;
} else {
setImmediate(() => this.start_pause());
}
} catch (error) {
console.warn("Error running program: ", error);
}
}
SvelteComponents.showDialog("Probe", {
probeType,
isRotaryActive: this.state["2an"] == 3,
});
},
},
mixins: [require("./axis-vars")],
mixins: [require("./program-mixin"), require("./axis-vars")],
};

607
src/js/program-mixin.js Normal file
View File

@@ -0,0 +1,607 @@
"use strict";
// Shared data, computed properties and methods that are used by both
// the Control view (for things like start/stop, run-macro, axis state)
// and the Program view (RUN/STOP/Upload/Download/Delete + file picker
// + gcode/path viewers). Splitting these out lets us mount the same
// behaviour under two top-level routes without duplicating code.
//
// The mixin intentionally does *not* require axis-vars; control-view
// keeps that one to itself.
const api = require("./api");
const utils = require("./utils");
module.exports = {
data: function () {
return {
mdi: "",
last_file: undefined,
last_file_time: undefined,
toolpath: {},
toolpath_progress: 0,
history: [],
speed_override: 1,
feed_override: 1,
deleteGCode: false,
folder_name: "",
edited: false,
uploading_files: false,
confirmDelete: false,
create_folder: false,
showGcodeMessage: false,
showNoGcodeMessage: false,
macrosLoading: false,
show_gcodes: false,
GCodeNotFound: false,
filesUploaded: 0,
totalFiles: 0,
files_sortby: "By Upload Date",
selected_items_to_delete: [],
search_query: "",
filtered_files: [],
selected_folder_index: null,
};
},
watch: {
"state.line": function () {
if (this.mach_state != "HOMING") {
this.$broadcast("gcode-line", this.state.line);
}
},
"state.selected_time": function () {
this.load();
},
},
computed: {
is_running: function () {
return this.mach_state == "RUNNING" || this.mach_state == "HOMING";
},
is_stopping: function () {
return this.mach_state == "STOPPING";
},
is_holding: function () {
return this.mach_state == "HOLDING";
},
is_ready: function () {
return this.mach_state == "READY";
},
is_idle: function () {
return this.state.cycle == "idle";
},
// True only while a loaded G-code program is actually being
// executed (running, paused/holding, or stopping). Excludes
// jogging, homing, probing, MDI commands and other one-off
// motion that also leave state.xx == "RUNNING" but must not
// swap the jog grid out for the "Now Running" panel.
//
// Distinguishing signal is state.cycle:
// - "idle" : nothing happening
// - "jogging" : user-initiated jog
// - "homing" : home cycle
// - "probing" : probe cycle
// - "mdi" : single MDI command
// - "running" : an actual loaded program is being run
// Only "running" (combined with a selected file) is what we want.
is_program_executing: function () {
if (!this.state) return false;
const xx = this.state.xx;
const cycle = this.state.cycle;
const isExecState = xx == "RUNNING" || xx == "HOLDING" || xx == "STOPPING";
if (!isExecState) return false;
// The cycle string narrows it to a real program run; anything
// else (jogging / homing / probing / mdi) is a one-off.
if (cycle && cycle != "running" && cycle != "idle") return false;
return !!this.state.selected;
},
is_paused: function () {
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
},
can_mdi: function () {
return this.is_idle || this.state.cycle == "mdi";
},
pause_reason: function () {
return this.state.pr;
},
plan_time: function () {
return this.state.plan_time;
},
plan_time_remaining: function () {
if (!(this.is_stopping || this.is_running || this.is_holding)) {
return 0;
}
return this.toolpath.time - this.plan_time;
},
eta: function () {
if (this.mach_state != "RUNNING") {
return "";
}
const remaining = this.plan_time_remaining;
const d = new Date();
d.setSeconds(d.getSeconds() + remaining);
return d.toLocaleString();
},
progress: function () {
if (!this.toolpath.time || this.is_ready) {
return 0;
}
const p = this.plan_time / this.toolpath.time;
return Math.min(1, p);
},
gcode_files: function () {
if (!this.state.folder) return [];
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
const folder = list.find(item => item.name == this.state.folder);
if (!folder) return [];
const stateFiles = Array.isArray(this.state.files) ? this.state.files : [];
const files = (folder.files || [])
.filter(item => stateFiles.includes(item.file_name))
.map(item => item.file_name);
if (this.files_sortby == "A-Z") return files.sort();
if (this.files_sortby == "Z-A") return files.sort().reverse();
return files;
},
gcode_filtered_files: function () {
return this.filtered_files.filter(file =>
file.toLowerCase().includes(this.search_query.toLowerCase()));
},
gcode_folders: function () {
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
return list
.map(item => item.name)
.filter(element => element !== "default")
.sort();
},
},
methods: {
save_config: async function (config) {
try {
await api.put("config/save", config);
this.$dispatch("update");
} catch (error) {
console.error("Restore Failed: ", error);
alert("Restore failed");
}
},
populateFiles(index) {
this.selected_folder_index = index;
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
},
send: function (msg) {
this.$dispatch("send", msg);
},
toggle_sorting: function () {
if (this.files_sortby === "By Upload Date") this.files_sortby = "A-Z";
else if (this.files_sortby === "A-Z") this.files_sortby = "Z-A";
else if (this.files_sortby === "Z-A") this.files_sortby = "By Upload Date";
},
load: function () {
const file_time = this.state.selected_time;
const file = this.state.selected;
if (this.last_file == file && this.last_file_time == file_time) return;
// state.files can be undefined briefly after connect, before the
// controller has pushed its file list. Skip the existence check
// until we have a list to consult.
const files = Array.isArray(this.state.files) ? this.state.files : null;
if (this.state.selected && files && !files.includes(this.state.selected)) {
this.GCodeNotFound = true;
return;
}
this.last_file = file;
this.last_file_time = file_time;
this.$broadcast("gcode-load", file);
this.$broadcast("gcode-line", this.state.line);
this.toolpath_progress = 0;
this.load_toolpath(file, file_time);
},
load_toolpath: async function (file, file_time) {
this.toolpath = {};
if (!file || this.last_file_time != file_time) return;
this.showGcodeMessage = true;
while (this.showGcodeMessage) {
try {
const toolpath = await api.get(`path/${file}`);
this.toolpath_progress = toolpath.progress;
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
this.showGcodeMessage = false;
if (toolpath.bounds) {
toolpath.filename = file;
this.toolpath_progress = 1;
this.toolpath = toolpath;
const state = this.$root.state;
for (const axis of "xyzabc") {
Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]);
Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]);
}
}
}
} catch (error) {
console.error(error);
}
}
},
submit_mdi: function () {
this.send(this.mdi);
if (!this.history.length || this.history[0] != this.mdi) {
this.history.unshift(this.mdi);
}
this.mdi = "";
},
mdi_start_pause: function () {
if (this.state.xx == "RUNNING") this.pause();
else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause();
else this.submit_mdi();
},
load_history: function (index) {
this.mdi = this.history[index];
},
open_file: function () {
utils.clickFileInput("gcode-file-input");
},
open_folder: function () {
utils.clickFileInput("gcode-folder-input");
},
edited_folder_name: function (event) {
if (event.target.value.trim() != "") {
this.$dispatch("folder_name_edited");
}
},
update_config: function () {
this.config.gcode_list = [...this.state.gcode_list];
this.config.non_macros_list = [...this.state.non_macros_list];
this.config.macros_list = [...this.state.macros_list];
this.config.macros = [...this.state.macros];
},
reset_gcode: function () {
this.state.selected = "";
this.last_file = "";
this.$broadcast("gcode-load", "");
},
upload_gcode: async function (filename, file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploading_files = false;
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve("file uploaded");
else { console.error("File upload failed:", xhr.statusText); reject("upload failed"); }
};
xhr.onerror = () => { alert("Upload failed."); reject("upload failed"); };
xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true);
xhr.send(file);
});
},
readFile: function (file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
reader.readAsText(file, "utf-8");
});
},
validateFiles: async function (files) {
const validFiles = [];
for (const file of files) {
const extension = file.name.split(".").pop().toLowerCase();
const validExtensions = ["nc", "ngc", "gcode", "gc"];
if (validExtensions.includes(extension)) {
validFiles.push(file);
} else {
alert(`Unsupported file : ${file.name}`);
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploadFiles = false;
}
}
}
return validFiles;
},
uploadValidFiles: async function (files, folderName) {
const updatedConfig = { ...this.config };
for (const file of files) {
try {
const gcode = await this.readFile(file);
await this.upload_gcode(file.name, gcode);
const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name);
if (!isAlreadyPresent) {
updatedConfig.non_macros_list.push({ file_name: file.name });
}
if (folderName) {
const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName);
if (folder) {
if (!folder.files.map(item => item.file_name).includes(file.name)) {
folder.files.push({ file_name: file.name });
}
} else {
updatedConfig.gcode_list.push({
name: folderName,
type: "folder",
files: [{ file_name: file.name }],
});
}
} else {
var folder_to_add = updatedConfig.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!folder_to_add) {
folder_to_add = updatedConfig.gcode_list.unshift({
name: this.state.folder,
type: "folder",
files: [{ file_name: file.name }],
});
folder_to_add = updatedConfig.gcode_list[0];
}
if (!folder_to_add.files.find(item => item.file_name == file.name)) {
folder_to_add.files.push({ file_name: file.name });
}
}
} catch (error) {
console.warn(`error uploading file : `, error);
}
}
return updatedConfig;
},
upload_files: async function (files, folderName) {
this.update_config();
const validFiles = await this.validateFiles(files);
const updatedConfig = await this.uploadValidFiles(validFiles, folderName);
await this.save_config(updatedConfig);
},
upload_file: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.totalFiles = files.length;
await this.upload_files(files);
},
create_new_folder: async function () {
const folder_name = this.folder_name.trim();
if (folder_name != "") {
if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) {
alert("Folder with the same name already exists!");
return;
}
this.update_config();
this.config.gcode_list.push({
name: folder_name,
type: "folder",
files: [],
});
this.state.folder = folder_name;
this.edited = false;
this.create_folder = false;
this.folder_name = "";
this.save_config(this.config);
}
},
cancel_new_folder: function () {
this.create_folder = false;
this.folder_name = "";
},
upload_folder: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.totalFiles = files.length;
const folderName = files[0].webkitRelativePath.split("/")[0];
this.upload_files(files, folderName);
},
delete_current: async function () {
if (!this.state.selected) {
this.deleteGCode = false;
return;
}
this.update_config();
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const folder_to_update = this.config.gcode_list.find(
item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder",
);
folder_to_update.files = folder_to_update.files.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const exception_list = this.state.macros_list.map(item => item.file_name);
let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item));
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.save_config(this.config);
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
cancel_delete: function () {
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_all: function () {
api.delete("file");
this.deleteGCode = false;
},
delete_all_except_macros: async function () {
this.update_config();
const macrosList = this.state.macros_list.map(item => item.file_name).toString();
api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`);
this.config.non_macros_list = [];
this.config.gcode_list = [{ name: "default", type: "folder", files: [] }];
this.save_config(this.config);
this.state.folder = "default";
this.state.selected = "";
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_folder: async function () {
this.update_config();
if (this.state.folder && this.state.folder != "default") {
const files_to_move = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (files_to_move) {
const default_folder = this.config.gcode_list.find(item => item.name == "default");
default_folder.files = [...default_folder.files, ...files_to_move.files].sort();
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
this.save_config(this.config);
}
}
this.state.folder = "default";
this.confirmDelete = false;
},
delete_folder_and_files: async function () {
if (!this.state.folder) {
this.confirmDelete = false;
return;
}
this.update_config();
const selected_folder = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!selected_folder) return;
const macrosList = this.state.macros_list.map(item => item.file_name);
var files_to_delete = selected_folder.files
.map(item => item.file_name)
.filter(item => !macrosList.includes(item));
if (selected_folder.name != "default") {
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
} else {
selected_folder.files = [];
}
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !files_to_delete.includes(item.file_name),
);
this.save_config(this.config);
this.state.folder = "default";
this.confirmDelete = false;
},
start_pause: function () {
this.macrosLoading = false;
if (this.state.xx == "RUNNING") this.pause();
else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause();
else this.start();
},
start: function () { api.put("start"); },
pause: function () { api.put("pause"); },
unpause: function () { api.put("unpause"); },
optional_pause: function () { api.put("pause/optional"); },
stop: function () { api.put("stop"); },
step: function () { api.put("step"); },
override_feed: function () { api.put(`override/feed/${this.feed_override}`); },
override_speed: function () { api.put(`override/speed/${this.speed_override}`); },
run_macro: async function (id) {
if (this.state.macros[id].file_name == "default") {
this.showNoGcodeMessage = true;
return;
}
const file_name = this.state.macros[id].file_name;
try {
// Selecting a file on the server is a side effect of
// GET /api/file/<name>. The macro button used to mutate
// state.selected client-side and immediately call start, which
// raced the file fetch: if the server hadn't seen the new
// selection yet, mach.start() ran whichever file was selected
// last. Do it explicitly and await so start always sees the
// right file.
if (file_name != this.state.selected) {
this.state.selected = file_name;
// GET /api/file/<name> returns gcode text (not JSON), so use
// fetch directly. The server's FileHandler.get sets
// state.selected as a side effect; we await the response
// before starting so mach.start() reads the right file.
const resp = await fetch(
`/api/file/${encodeURIComponent(file_name)}`,
{ cache: "no-cache" }
);
if (!resp.ok) {
throw new Error(`file fetch failed: ${resp.status}`);
}
await resp.text();
}
this.load();
if (this.state.macros[id].alert == true) {
this.macrosLoading = true;
} else {
await this.start_pause();
}
} catch (error) {
console.warn("Error running macro: ", error);
}
},
},
};

62
src/js/program-view.js Normal file
View File

@@ -0,0 +1,62 @@
"use strict";
// Program tab — file management, run/stop, gcode listing and 3D
// toolpath preview. Reuses the shared mixin (program-mixin) that also
// powers the legacy bits of control-view; this view does not host the
// jog grid or the DRO.
module.exports = {
template: "#program-view-template",
props: ["config", "template", "state"],
components: {
"path-viewer": require("./path-viewer"),
"gcode-viewer": require("./gcode-viewer"),
},
data: function () {
return {};
},
watch: {
"state.metric": {
handler: function () {},
immediate: true,
},
},
computed: {
is_kiosk: function () { return !!this.$root.is_kiosk; },
display_units: {
cache: false,
get: function () { return this.$root.display_units; },
set: function (value) {
this.config.settings.units = value;
this.$root.display_units = value;
this.$dispatch("config-changed");
},
},
metric: function () {
return this.display_units === "METRIC";
},
mach_state: function () {
const cycle = this.state.cycle;
const xx = this.state.xx;
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
return cycle.toUpperCase();
}
return xx || "";
},
can_set_axis: function () { return this.state.cycle == "idle"; },
},
ready: function () {
this.load();
},
mixins: [require("./program-mixin")],
};

View File

@@ -0,0 +1,169 @@
"use strict";
// Wrapper that adds a left-rail navigator around the settings family
// of views (Settings, Admin General, Admin Network, Tool, IO, Motor,
// Macros, Help, Cheat Sheet). The inner view is selected by the URL
// hash (parsed in app.js) and exposed as $root.sub_tab.
// Vue 1 has trouble making child components reactive to `$root.sub_tab`
// changes (whether via computed, watch, or prop binding through
// `<component :is>`). The shell instead listens to `hashchange`
// directly and parses the hash itself, mirroring app.js's logic, then
// keeps a local data prop `sub` that the template binds to. This is
// the only path that updates the rail's `:class` reactively.
module.exports = {
template: "#settings-shell-view-template",
props: ["config", "template", "state", "index"],
components: {
"settings-view-inner": require("./settings-view"),
"admin-general-view": require("./admin-general-view"),
"admin-network-view": require("./admin-network-view"),
"motor-view": require("./motor-view"),
"tool-view": require("./tool-view"),
"io-view": require("./io-view"),
"macros-view": require("./macros"),
"help-view": require("./help-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function () {
return { showUnimplemented: false };
},
},
},
data: function () {
return {
sub: this.$root.sub_tab || "settings",
ridx: this.$root.index, // local copy of the motor index
// Whether the controller config has streamed in. The Svelte
// settings views crash on first paint with the placeholder
// config (settings.units / settings.easy-adapter / motion.*
// are all undefined). Gate the inner mount on this flag.
config_ready: false,
rail_items: [
{ sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" },
{ sub: "probing", href: "#probing", icon: "fa-bullseye", label: "Probing" },
{ sub: "gcode", href: "#gcode", icon: "fa-code", label: "G-code & Motion" },
{ sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
{ sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" },
{ sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" },
{ sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" },
{ sub: "io", href: "#io", icon: "fa-plug", label: "I/O" },
{ section: "Motors" },
{ sub: "motor", motor: 0, href: "#motor:0", icon: "fa-arrows-up-down-left-right", label: "Motor 0" },
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
{ sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" },
{ sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
{ section: " " },
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
],
};
},
ready: function () {
this._onHash = () => this.refresh_from_hash();
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
this._configPoll = setInterval(() => {
const c = this.$root && this.$root.config;
const ready = !!(c && c.full_version && c.full_version !== "<loading>"
&& c.settings && typeof c.settings === "object");
if (ready !== this.config_ready) this.config_ready = ready;
}, 200);
},
attached: function () {
// Vue 1 fires `attached` whenever the component is inserted into
// the DOM (which happens on every route change because the outer
// <component :is> recreates the instance). Re-bind the listener
// here so it works even after detach/attach cycles.
if (!this._onHash) {
this._onHash = () => this.refresh_from_hash();
}
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
},
detached: function () {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
},
beforeDestroy: function () {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
if (this._configPoll) clearInterval(this._configPoll);
},
methods: {
refresh_from_hash: function () {
const hash = location.hash.substr(1) || "settings";
const parts = hash.split(":");
this.sub = parts[0] || "settings";
this.ridx = parts[1] !== undefined ? parts[1] : -1;
},
is_active: function (item) {
if (!item || item.section) return false;
if (item.sub !== this.sub) return false;
if (item.sub === "motor") {
return "" + item.motor === "" + this.ridx;
}
return true;
},
on_rail_click: function (item, ev) {
if (!item) return;
// Always preventDefault on rail clicks. Letting the browser
// anchor-scroll to <div id="settings"> etc. inside .app-body
// can pull the .app-head out of view; we drive navigation
// ourselves through location.hash and our hashchange handler.
if (ev && ev.preventDefault) ev.preventDefault();
if (item.anchor) {
// Soft-link rail items use a #settings hash plus an in-page
// anchor scroll once the Svelte page has mounted. We scroll
// ONLY the .settings-content overflow container by setting
// its scrollTop directly — element.scrollIntoView() walks all
// ancestor scroll containers and can tug the .app-body / html
// layout, which under tablet mode pulls the fixed header out
// of view.
if (location.hash !== item.href) location.hash = item.href;
const reset = () => {
// Force any inadvertent ancestor scroll back to 0 before
// we move .settings-content explicitly.
window.scrollTo(0, 0);
const body = document.querySelector(".app-body");
if (body) body.scrollTop = 0;
document.documentElement.scrollTop = 0;
};
setTimeout(() => {
reset();
const el = document.getElementById(item.anchor);
const scroller = document.querySelector(".settings-content");
if (el && scroller) {
const elTop = el.getBoundingClientRect().top;
const scTop = scroller.getBoundingClientRect().top;
scroller.scrollTop = scroller.scrollTop + (elTop - scTop) - 12;
}
// Re-assert ancestor scroll = 0 in case the assignment above
// moved things.
requestAnimationFrame(reset);
}, 320);
} else {
if (location.hash !== item.href) location.hash = item.href;
// Reset .app-body scroll so each route starts at the top.
const body = document.querySelector(".app-body");
if (body) body.scrollTop = 0;
}
},
showShutdownDialog: function () {
SvelteComponents.showDialog("Shutdown");
},
},
};

View File

@@ -1,14 +1,60 @@
// V09 wraps the legacy Svelte SettingsView and filters its big page
// down to a single rail section so each rail item shows only the
// relevant controls. The Svelte component is left untouched (it is
// shared with the legacy UI) — we just hide the `<h2>` and `<fieldset>`
// elements whose `data-sec` does not match the active section.
module.exports = {
template: "#settings-view-template",
attached: function() {
props: {
// "display" | "probing" | "gcode". Default is "display" which
// keeps the rail's "Display & Units" item working unchanged.
section: { default: "display" },
},
attached: function () {
this.svelteComponent = SvelteComponents.createComponent(
"SettingsView",
document.getElementById("settings")
);
// Defer one tick so Svelte has rendered the section markup.
setTimeout(() => this.apply_section_filter(), 0);
},
detached: function() {
this.svelteComponent.$destroy();
}
detached: function () {
if (this.svelteComponent) this.svelteComponent.$destroy();
},
watch: {
section: function () {
this.apply_section_filter();
},
},
methods: {
apply_section_filter: function () {
const root = document.getElementById("settings");
if (!root) return;
const want = this.section || "display";
// Hide every section block that does not match.
root.querySelectorAll("[data-sec]").forEach(el => {
el.style.display = el.dataset.sec === want ? "" : "none";
});
// Hide the global <h1>Settings</h1> on subsections so the
// page reads as a focused panel.
const h1 = root.querySelector(".settings-view > h1");
if (h1) {
if (want === "display") {
h1.textContent = "Display & Units";
} else if (want === "probing") {
h1.textContent = "Probing";
} else if (want === "gcode") {
h1.textContent = "G-code & Motion";
} else {
h1.textContent = "Settings";
}
}
},
},
};