UX redesign V09: replace shell, split Program/Console/Settings

Implements the V09 mock end-to-end (per plans/2026-04-30_ux_redesign.md):

Top shell
- index.pug rebuilt around .app-shell with a slim 96px header.
- Underline-ribbon tab bar (Control / Program / Console / Settings)
  replaces the old side menu and the inline #tab1..#tab4 system.
- Single 'All systems' pill collapses the legacy WiFi/Camera/Rotary/
  IP/Version chip-soup into one popover (sys-popover) anchored to the
  header; rotary toggle, camera feed and shutdown live there.
- Octagonal 88x88px STOP button wraps the existing <estop> SVG; STATE
  pill with pulse-dot honors prefers-reduced-motion.

Routing
- app.js parse_hash maps every existing hash:
    #control                       -> Control
    #program / #program:auto       -> Program
    #console / #console:mdi|messages|indicators -> Console
    #settings, #admin-general,
    #admin-network, #motor:N, #tool, #io, #macros, #help,
    #cheat-sheet                  -> Settings (rail picks inner)
- All deep links are preserved.

Control panel (control-view.pug + .js)
- 720px jog grid + 4-axis DRO + 4 KPI cards + 8-macro row.
- Jog tiles use V09 flat slate (#3f4b63) with diagonal helpers and
  a ghost row for XY/Z origin shortcuts.
- Per-axis Settings/Set-zero/Home buttons grow to 72x72px.
- Status strip cards: State / Velocity-Feed / Spindle / Job. Tapping
  the Spindle card opens the new override-drawer with feed + spindle
  range inputs (resolved decision in plans/...).
- Macro row binds to state.macros.slice(0, 8); >8 lives in Settings.
- Drops the old <table> control-buttons, .info, .override and .tabs
  blocks entirely.

Program panel (program-view.pug + .js)
- Extracts the Auto bar, file selectors, gcode-viewer and path-viewer
  out of control-view.
- Action buttons (RUN/STOP/UPLOAD-FOLDER/UPLOAD-FILE/DOWNLOAD-FILE/
  DELETE) at 84px with explicit color affordances.
- Reuses control-view's existing methods via the new program-mixin.

Console panel (console-view.pug + .js)
- Three sub-tabs: MDI / Messages / Indicators. Sub-tab persists in the
  URL fragment (#console:messages etc.).
- MDI: terminal-style prompt + SEND, plus an 8-wide on-screen keypad
  (G0/G1/G2/G3/G28/G92/M3/M5 + axis letters + CLEAR/SEND).
- Messages: pulls from .messages_log (mirrored from
  state.messages); badge in the header tab counts unread.
- Indicators: mounts the existing <indicators> component.

Settings shell (settings-shell.pug + .js)
- New left rail navigator listing Display, Network, General/Firmware,
  Spindle&Tool, IO, Motors 0..3, Macros, Cheat Sheet, Help.
- Inner area mounts the existing settings family templates via an
  explicit v-if cascade (avoiding a Vue 1 :is reactivity quirk).
- Shutdown / Save buttons relocated from the dropped side menu.

JS plumbing
- main.js: Vue.config.async = false to keep dependent watchers in
  sync when reactive data is mutated outside Vue's normal event loop
  (e.g. from a hashchange listener).
- program-mixin.js extracted so control-view.js no longer carries the
  file/macro/gcode methods that are now Program-only.
- control-view.js trimmed to jog/DRO/probe/home logic.
- console-view.js / settings-shell-view.js use a hashchange listener
  + local data props because Vue 1 cannot reliably observe
  .sub_tab from a child component.

Stylus rewrite
- Removes the old .header (140px), .nav-header, .brand subtree, #menu,
  #main, .control-view block, .info, .override, .toolbar, .macros-div,
  .macros-button, the .tabs > input radio-tab system and the .control-
  view #control media-query overrides. None of these are referenced
  any more.
- Adds V09 tokens (jog/macro palette + accent + line/card colors) at
  the top, the new shell rules, .ktab / .sys-btn / .state-badge /
  .estop chrome, the .control-page grid, status strip + override
  drawer, .program-page action / file bars and program body,
  .console-page MDI keypad / messages / indicators panes, and the
  .settings-shell rail.
- Adds a 1820px breakpoint that stacks the right column under the jog
  on smaller portable monitors.

Hard cut: no config.ui.layout flag, the old shell is removed in this
single commit. side-menu.css is no longer included from index.pug.

Tested locally with agent-browser (1920x1080) on every top tab and
every settings sub-route; routing, active tab highlighting and inner
view selection all work without a controller connection.
This commit is contained in:
2026-04-30 21:27:00 +02:00
parent 081209decf
commit 32f3aca368
13 changed files with 3044 additions and 1907 deletions

View File

@@ -103,12 +103,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 +154,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 +170,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: {
@@ -252,18 +275,106 @@ 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();
// 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.
this.parse_hash();
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();
@@ -421,6 +532,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 +556,56 @@ 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", "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() {

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,25 +180,9 @@ 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(";");
},
@@ -324,426 +201,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;
@@ -777,11 +234,8 @@ module.exports = {
});
},
// Use the same fine/small/medium/large increment buttons as the XYZ
// jog grid. sign=+1 for W+, -1 for W-.
aux_jog_incr: function (sign) {
const amount =
this.jog_incr_amounts[this.display_units][this.jog_incr];
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
const delta_mm = sign * (this.metric ? amount : amount * 25.4);
this.aux_jog(delta_mm);
},
@@ -811,93 +265,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")],
};

View File

@@ -44,6 +44,16 @@ window.onload = function() {
cookie_set("client-id", uuid(), 10000);
}
// Vue 1's async queue can drop dependent watcher updates when
// data props are mutated outside the normal event flow (e.g. from
// a `hashchange` listener that fires before Vue's tick scheduler
// has caught up). Disable async batching so every reactive write
// synchronously re-evaluates dependents — this matches Vue 1's
// older default and is what the legacy UI implicitly relied on.
if (Vue.config) {
Vue.config.async = false;
}
// Register global components
Vue.component("templated-input", require("./templated-input"));
Vue.component("message", require("./message"));

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

@@ -0,0 +1,554 @@
"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";
},
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 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();
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 () {
return this.state.gcode_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;
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;
}
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: 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);
}
}
},
},
};

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

@@ -0,0 +1,60 @@
"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: {
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,109 @@
"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
rail_items: [
{ sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" },
{ 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: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
{ 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();
},
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);
}
},
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;
},
showShutdownDialog: function () {
SvelteComponents.showDialog("Shutdown");
},
},
};