Review Changes
' +
- renderPane(rows, "left") +
- renderPane(rows, "right") +
- '
';
- document.body.appendChild(overlay);
-
- const panes = overlay.querySelectorAll(".dv-pane");
- let syncing = false;
- panes.forEach((pane) => {
- pane.addEventListener("scroll", () => {
- if (syncing) return;
- syncing = true;
- panes.forEach((other) => {
- if (other !== pane && other.scrollTop !== pane.scrollTop) {
- other.scrollTop = pane.scrollTop;
- }
- });
- requestAnimationFrame(() => {
- syncing = false;
- });
- });
- });
-
- function close() {
- if (overlay && overlay.parentNode) overlay.remove();
- }
- overlay.querySelector(".dv-close").addEventListener("click", () => {
- close();
- onCancel();
- });
- overlay.addEventListener("click", (e) => {
- if (e.target === overlay) {
- close();
- onCancel();
- }
- });
- overlay
- .querySelector('[data-act="cancel"]')
- .addEventListener("click", () => {
- close();
- onCancel();
- });
- overlay
- .querySelector('[data-act="confirm"]')
- .addEventListener("click", () => {
- close();
- onConfirm();
- });
- }
-
- window.DiffViewer = { show };
-})();
diff --git a/Web/editor.css b/Web/editor.css
deleted file mode 100644
index b98f10809..000000000
--- a/Web/editor.css
+++ /dev/null
@@ -1,800 +0,0 @@
-/* ==================== Sidebar Buttons ==================== */
-
-.sidebar-btn {
- display: block;
- width: 100%;
- padding: 10px 14px;
- margin-top: 14px;
- font-family: inherit;
- font-size: 0.82rem;
- font-weight: 600;
- border-radius: 8px;
- border: 1px solid #2c3350;
- background: #0a0c13;
- color: #b8c3d8;
- cursor: pointer;
- transition:
- background 0.15s,
- border-color 0.15s,
- color 0.15s;
-}
-
-.sidebar-btn:hover {
- background: #131729;
- border-color: #3d4872;
- color: #f1f5f9;
-}
-
-.sidebar-btn-edit.active {
- background: rgba(124, 58, 237, 0.18);
- border-color: #7c3aed;
- color: #c4b5fd;
-}
-
-.sidebar-btn-download {
- background: rgba(124, 58, 237, 0.12);
- border-color: #7c3aed;
- color: #c4b5fd;
-}
-
-.sidebar-btn-download:hover {
- background: rgba(124, 58, 237, 0.28);
- border-color: #8b5cf6;
- color: #e9d5ff;
-}
-
-.sidebar-btn-graph {
- background: rgba(14, 165, 233, 0.1);
- border-color: #0ea5e9;
- color: #7dd3fc;
-}
-
-.sidebar-btn-graph:hover {
- background: rgba(14, 165, 233, 0.22);
- border-color: #38bdf8;
- color: #bae6fd;
-}
-
-/* ==================== Editor Icon Buttons ==================== */
-
-.editor-icons {
- display: inline-flex;
- gap: 2px;
- margin-left: 6px;
- flex-shrink: 0;
-}
-
-.edited-item {
- border-left-color: #f59e0b !important;
-}
-
-.edited-pill {
- margin-left: 6px;
- padding: 1px 6px;
- border-radius: 999px;
- font-size: 0.62rem;
- color: #fef3c7;
- background: rgba(245, 158, 11, 0.22);
- border: 1px solid rgba(245, 158, 11, 0.5);
-}
-
-.editor-icon-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- padding: 0;
- border: 1px solid transparent;
- border-radius: 6px;
- background: transparent;
- color: #7f8ca4;
- cursor: pointer;
- transition:
- background 0.12s,
- color 0.12s,
- border-color 0.12s;
-}
-
-.editor-icon-btn:hover {
- background: rgba(255, 255, 255, 0.06);
- border-color: #2c3350;
-}
-
-.editor-icon-edit:hover {
- color: #c4b5fd;
-}
-
-.editor-icon-delete {
- color: #7f8ca4;
-}
-
-.editor-icon-delete:hover {
- color: #ef4444;
- background: rgba(239, 68, 68, 0.1);
- border-color: rgba(239, 68, 68, 0.25);
-}
-
-/* ==================== Add Button ==================== */
-
-.editor-add-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- width: calc(100% - 20px);
- margin: 8px 10px;
- padding: 8px 12px;
- font-family: inherit;
- font-size: 0.78rem;
- font-weight: 500;
- color: #7f8ca4;
- background: transparent;
- border: 1px dashed #2c3350;
- border-radius: 8px;
- cursor: pointer;
- transition:
- background 0.12s,
- color 0.12s,
- border-color 0.12s;
-}
-
-.editor-add-btn:hover {
- color: #c4b5fd;
- background: rgba(124, 58, 237, 0.08);
- border-color: #7c3aed;
-}
-
-/* ==================== Message Name Row ==================== */
-
-.msg-name-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
-}
-
-.msg-edited-row {
- margin-top: 2px;
-}
-
-.msg-edited-badge,
-.status-badge {
- display: inline-block;
- padding: 2px 7px;
- border-radius: 999px;
- font-size: 0.68rem;
- font-weight: 700;
- letter-spacing: 0.02em;
-}
-
-.status-badge.status-changed {
- color: #422006;
- background: #facc15;
- border: 1px solid #eab308;
-}
-
-.status-badge.status-new {
- color: #3b2600;
- background: #fde047;
- border: 1px solid #f59e0b;
-}
-
-.changed-dot {
- display: inline-block;
- width: 8px;
- height: 8px;
- border-radius: 999px;
- background: #facc15;
- box-shadow: 0 0 0 1px #eab308;
- margin-left: 6px;
- vertical-align: middle;
-}
-
-.download-notice {
- position: fixed;
- bottom: 24px;
- left: 50%;
- transform: translateX(-50%);
- z-index: 2300;
- display: flex;
- align-items: center;
- gap: 8px;
- background: #1e293b;
- color: #e2e8f0;
- border: 1px solid #334155;
- border-left: 4px solid #38bdf8;
- border-radius: 6px;
- padding: 10px 14px;
- font-size: 13px;
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
- max-width: 480px;
- pointer-events: all;
-}
-
-.download-notice-icon {
- flex-shrink: 0;
-}
-
-.download-notice-msg {
- flex: 1;
-}
-
-.download-notice-close {
- flex-shrink: 0;
- background: none;
- border: none;
- color: #94a3b8;
- cursor: pointer;
- font-size: 16px;
- line-height: 1;
- padding: 0 2px;
-}
-
-.download-notice-close:hover {
- color: #e2e8f0;
-}
-
-.dv-overlay {
- position: fixed;
- inset: 0;
- z-index: 2200;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0, 0, 0, 0.66);
-}
-
-.dv-modal {
- width: min(1200px, 96vw);
- height: min(86vh, 860px);
- display: flex;
- flex-direction: column;
- background: #0e1017;
- border: 1px solid #1f2439;
- border-radius: 12px;
- overflow: hidden;
-}
-
-.dv-head,
-.dv-foot {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- border-bottom: 1px solid #1f2439;
-}
-
-.dv-foot {
- border-bottom: none;
- border-top: 1px solid #1f2439;
- justify-content: flex-end;
- gap: 8px;
-}
-
-.dv-head h2 {
- margin: 0;
- font-size: 1rem;
-}
-
-.dv-close {
- background: none;
- border: none;
- color: #94a3b8;
- font-size: 1.4rem;
- cursor: pointer;
-}
-
-.dv-body {
- flex: 1;
- display: flex;
- gap: 1px;
- background: #1f2439;
- min-height: 0;
- overflow: hidden;
-}
-
-.dv-pane {
- flex: 1 1 50%;
- min-width: 0;
- overflow: auto;
- background: #0e1017;
-}
-
-.dv-table {
- width: 100%;
- border-collapse: collapse;
- font-family: "Fira Mono", "Consolas", monospace;
- font-size: 0.74rem;
-}
-
-.dv-table th,
-.dv-table td {
- padding: 3px 6px;
- border-bottom: 1px solid #14192d;
- vertical-align: top;
- white-space: pre;
- line-height: 1.4;
-}
-
-.dv-table td:empty::after {
- content: "\00a0";
-}
-
-.dv-table thead th {
- position: sticky;
- top: 0;
- background: #0e1017;
- z-index: 1;
- text-align: left;
-}
-
-.dv-ln {
- width: 58px;
- text-align: right;
- color: #64748b;
- background: #090d18;
-}
-
-.dv-table thead th.dv-ln {
- background: #090d18;
- text-align: right;
-}
-
-.dv-code {
- color: #cbd5e1;
-}
-
-.dv-code-head {
- text-align: left;
-}
-
-.dv-del {
- background: rgba(239, 68, 68, 0.14);
-}
-
-.dv-add {
- background: rgba(34, 197, 94, 0.14);
-}
-
-.dv-btn {
- padding: 7px 12px;
- border-radius: 8px;
- border: 1px solid #2c3350;
- background: #131729;
- color: #dbe2ef;
- cursor: pointer;
-}
-
-.dv-btn-primary {
- background: #7c3aed;
- border-color: #7c3aed;
- color: #fff;
-}
-
-/* ==================== Modal Overlay ==================== */
-
-.editor-overlay {
- position: fixed;
- inset: 0;
- z-index: 2000;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(4px);
-}
-
-.editor-modal {
- width: min(680px, 92vw);
-}
-
-.editor-modal {
- max-height: 88vh;
- display: flex;
- flex-direction: column;
- background: #0e1017;
- border: 1px solid #1f2439;
- border-radius: 14px;
- box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
- overflow: hidden;
-}
-
-.editor-modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 18px 22px 14px;
- border-bottom: 1px solid #1f2439;
-}
-
-.editor-modal-header h2 {
- margin: 0;
- font-size: 1.1rem;
- font-weight: 600;
- color: #f1f5f9;
-}
-
-.editor-modal-close {
- background: none;
- border: none;
- color: #7f8ca4;
- font-size: 1.5rem;
- cursor: pointer;
- padding: 0 4px;
- line-height: 1;
-}
-
-.editor-modal-close:hover {
- color: #f1f5f9;
-}
-
-.editor-modal-body {
- flex: 1;
- overflow-y: auto;
- padding: 18px 22px;
-}
-
-.editor-modal-body::-webkit-scrollbar {
- width: 6px;
-}
-
-.editor-modal-body::-webkit-scrollbar-thumb {
- background: #303b68;
- border-radius: 4px;
-}
-
-.editor-modal-footer {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- padding: 14px 22px;
- border-top: 1px solid #1f2439;
-}
-
-/* ==================== Form Elements ==================== */
-
-.editor-form-row {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-bottom: 12px;
- position: relative;
-}
-
-.editor-label {
- font-size: 0.72rem;
- font-weight: 600;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: #7f8ca4;
-}
-
-.editor-required {
- color: #ef4444;
-}
-
-.editor-input {
- width: 100%;
- font-family: inherit;
- font-size: 0.82rem;
- padding: 8px 10px;
- border-radius: 7px;
- border: 1px solid #2c3350;
- background: #0a0c13;
- color: #ecf2ff;
- outline: none;
- transition:
- border-color 0.15s,
- box-shadow 0.15s;
-}
-
-.editor-input:focus {
- border-color: #7c3aed;
- box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.18);
-}
-
-.editor-input:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-select.editor-input {
- appearance: none;
- background-image:
- linear-gradient(45deg, transparent 50%, #7f8ca4 50%),
- linear-gradient(135deg, #7f8ca4 50%, transparent 50%);
- background-position:
- calc(100% - 14px) calc(50% - 2px),
- calc(100% - 9px) calc(50% - 2px);
- background-size:
- 5px 5px,
- 5px 5px;
- background-repeat: no-repeat;
- padding-right: 28px;
-}
-
-textarea.editor-input {
- resize: vertical;
- min-height: 48px;
-}
-
-.editor-error {
- font-size: 0.7rem;
- color: #ef4444;
- min-height: 0;
-}
-
-/* ==================== Buttons ==================== */
-
-.editor-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 8px 16px;
- font-family: inherit;
- font-size: 0.82rem;
- font-weight: 500;
- border-radius: 8px;
- border: 1px solid #2c3350;
- background: #131729;
- color: #b8c3d8;
- cursor: pointer;
- transition:
- background 0.12s,
- border-color 0.12s,
- color 0.12s;
-}
-
-.editor-btn:hover {
- background: #1a2040;
- border-color: #3d4872;
- color: #f1f5f9;
-}
-
-.editor-btn-primary {
- background: #7c3aed;
- border-color: #7c3aed;
- color: #fff;
-}
-
-.editor-btn-primary:hover {
- background: #6d28d9;
- border-color: #6d28d9;
-}
-
-.editor-btn-danger {
- background: rgba(239, 68, 68, 0.15);
- border-color: rgba(239, 68, 68, 0.35);
- color: #fca5a5;
-}
-
-.editor-btn-danger:hover {
- background: rgba(239, 68, 68, 0.25);
- border-color: #ef4444;
- color: #fecaca;
-}
-
-.editor-btn-sm {
- padding: 5px 10px;
- font-size: 0.74rem;
-}
-
-/* ==================== Section Headers ==================== */
-
-.editor-section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin: 16px 0 10px;
- padding-bottom: 8px;
- border-bottom: 1px solid #1f2439;
- font-size: 0.85rem;
- font-weight: 600;
- color: #c4b5fd;
-}
-
-/* ==================== Field Cards ==================== */
-
-.editor-fields-box {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.editor-field-card {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 12px;
- background: rgba(15, 17, 26, 0.7);
- border: 1px solid #1a2038;
- border-radius: 10px;
-}
-
-.editor-field-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 10px;
-}
-
-.editor-field-grid-5 {
- grid-template-columns: repeat(5, 1fr);
-}
-
-.editor-field-grid-4 {
- grid-template-columns: repeat(4, 1fr);
-}
-
-.editor-field-comment-row textarea {
- width: 100%;
- min-height: 64px;
- resize: vertical;
-}
-
-.editor-field-controls {
- display: flex;
- justify-content: flex-end;
- gap: 6px;
- margin-top: 8px;
-}
-
-/* ==================== Message Suggestions (Add Route) ==================== */
-
-.editor-suggest {
- position: absolute;
- z-index: 2100;
- top: 100%;
- left: 0;
- padding: 4px 0;
- min-width: 100%;
- max-height: 220px;
- overflow-y: auto;
- border-radius: 8px;
- border: 1px solid #1f2439;
- background: #020617;
- box-shadow: 0 10px 25px rgba(0, 0, 0, 0.6);
-}
-
-.editor-suggest.hidden {
- display: none;
-}
-
-.editor-suggest-item {
- padding: 4px 10px;
- font-size: 0.8rem;
- color: #cbd5f5;
- cursor: pointer;
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
-}
-
-.editor-suggest-item:hover,
-.editor-suggest-item-active {
- background: #1e293b;
- color: #f9fafb;
-}
-
-@media (max-width: 768px) {
- .editor-field-grid {
- grid-template-columns: 1fr;
- }
-
- .editor-field-grid-5 {
- grid-template-columns: repeat(2, 1fr);
- }
-
- .editor-field-grid-4 {
- grid-template-columns: repeat(2, 1fr);
- }
-
- /* ── Full-screen modal sheet on mobile ── */
- .editor-overlay {
- padding: 0;
- backdrop-filter: none;
- }
-
- .editor-modal {
- width: 100vw;
- max-width: 100vw;
- height: 100vh;
- height: 100dvh;
- max-height: 100dvh;
- border-radius: 0;
- border: none;
- }
-
- .editor-modal-header {
- position: sticky;
- top: 0;
- z-index: 2;
- background: #0e1017;
- padding: 14px 16px 12px;
- padding-top: var(--sat, 14px);
- }
-
- .editor-modal-body {
- padding: 14px 16px;
- -webkit-overflow-scrolling: touch;
- }
-
- .editor-modal-footer {
- position: sticky;
- bottom: 0;
- z-index: 2;
- background: #0e1017;
- padding: 12px 16px;
- padding-bottom: var(--sab, 12px);
- }
-
- /* iOS zoom-on-focus prevention — inputs in modals must be ≥16px */
- .editor-modal input,
- .editor-modal select,
- .editor-modal textarea,
- .editor-input {
- font-size: 16px;
- }
-
- /* Touch targets */
- .editor-icon-btn {
- min-width: 44px;
- min-height: 44px;
- }
-
- .editor-modal-close {
- min-width: 44px;
- min-height: 44px;
- }
-
- .editor-btn {
- min-height: 44px;
- padding: 10px 16px;
- }
-
- .editor-btn-sm {
- min-height: 36px;
- }
-
- /* Autocomplete dropdown — clamp so it never extends past sticky footer */
- .editor-suggest {
- max-height: min(280px, 40dvh);
- }
-
- /* Diff viewer modal mirrors the full-screen sheet treatment */
- .dv-modal {
- width: 100vw;
- max-width: 100vw;
- height: 100vh;
- height: 100dvh;
- max-height: 100dvh;
- border-radius: 0;
- }
-
- .dv-btn {
- min-height: 44px;
- }
-
- .download-notice {
- left: 12px;
- right: 12px;
- transform: none;
- max-width: none;
- bottom: 16px;
- }
-}
-
-/* ==================== Confirm Dialog ==================== */
-
-.editor-confirm-msg {
- margin: 0 0 12px;
- font-size: 0.92rem;
- color: #f1f5f9;
-}
-
-.editor-confirm-warn {
- margin: 0;
- font-size: 0.78rem;
- color: #9aa3b0;
- font-style: italic;
-}
diff --git a/Web/editor.js b/Web/editor.js
deleted file mode 100644
index 6fe1d4778..000000000
--- a/Web/editor.js
+++ /dev/null
@@ -1,513 +0,0 @@
-// Purpose: Core CANdo text mutation engine. Owns all in-memory editing state
-// (rawCandoText, editMode, editedKeys, newKeys) and exposes low-level operations
-// for finding, inserting, replacing, and deleting YAML line ranges. Also provides
-// icon-button DOM factories (createEditBtn, createDeleteBtn, createAddBtn) used by
-// viewer.js to inject editor controls into list items.
-// Individual form modals are defined in their own form*.js files, which each
-// augment this object by assigning their show* / confirm* methods onto it.
-// Depends on: formUtils.js (for SVG icon strings on icon buttons).
-// Exposed as: window.GrcanEditor
-
-(function () {
- "use strict";
-
- // ==================== State ====================
-
- let editMode = false;
- let rawCandoText = "";
- let originalRawCandoText = "";
- let hasEdits = false;
- let reRenderCallback = null;
- let pendingNavSnapshot = null;
- const editedKeys = new Set();
- const newKeys = new Set();
-
- // ==================== Line-Range Helpers ====================
-
- function getLines() {
- return rawCandoText.split("\n");
- }
-
- function findSectionStart(lines, sectionName) {
- return lines.findIndex((l) => l.startsWith(sectionName + ":"));
- }
-
- function findSectionEnd(lines, startIdx) {
- for (let i = startIdx + 1; i < lines.length; i++) {
- if (/^\S/.test(lines[i]) && lines[i].trim() !== "") return i;
- }
- return lines.length;
- }
-
- function findBlockEnd(lines, startIdx, maxIdx, baseIndent) {
- let end = startIdx + 1;
- while (end < maxIdx) {
- const ni = lines[end].search(/\S/);
- if (ni !== -1 && ni <= baseIndent) break;
- end++;
- }
- return end;
- }
-
- function findEntryInSection(sectionName, entryName, indent) {
- const lines = getLines();
- const secStart = findSectionStart(lines, sectionName);
- if (secStart === -1) return null;
- const secEnd = findSectionEnd(lines, secStart);
- for (let i = secStart + 1; i < secEnd; i++) {
- const line = lines[i];
- if (line.search(/\S/) === indent && line.trim() === entryName + ":") {
- return {
- startLine: i,
- endLine: findBlockEnd(lines, i, secEnd, indent),
- };
- }
- }
- return null;
- }
-
- function findMessageDefRange(msgName) {
- return findEntryInSection("Message ID", msgName, 2);
- }
-
- function findCustomCanIdRange(msgName) {
- return findEntryInSection("Custom CAN ID", msgName, 2);
- }
-
- function findRoutingDeviceRange(deviceName) {
- const lines = getLines();
- const rStart = findSectionStart(lines, "routing");
- if (rStart === -1) return null;
- const rEnd = findSectionEnd(lines, rStart);
- for (let i = rStart + 1; i < rEnd; i++) {
- if (lines[i].search(/\S/) === 4 && lines[i].trim() === deviceName + ":") {
- return { startLine: i, endLine: findBlockEnd(lines, i, rEnd, 4) };
- }
- }
- return null;
- }
-
- function findRoutingBusRange(deviceName, busPort) {
- const devRange = findRoutingDeviceRange(deviceName);
- if (!devRange) return null;
- const lines = getLines();
- for (let i = devRange.startLine + 1; i < devRange.endLine; i++) {
- if (lines[i].search(/\S/) === 6 && lines[i].trim() === busPort + ":") {
- return {
- startLine: i,
- endLine: findBlockEnd(lines, i, devRange.endLine, 6),
- };
- }
- }
- return null;
- }
-
- function findRoutingMsgEntries(deviceName, busPort, msgName) {
- const busRange = findRoutingBusRange(deviceName, busPort);
- if (!busRange) return [];
- const lines = getLines();
- const results = [];
- for (let i = busRange.startLine + 1; i < busRange.endLine; i++) {
- if (
- lines[i].search(/\S/) === 10 &&
- lines[i].trim() === "- msg: " + msgName
- ) {
- let end = i + 1;
- if (
- end < busRange.endLine &&
- lines[end].search(/\S/) === 12 &&
- lines[end].trim().startsWith("can_id_override:")
- ) {
- end++;
- }
- results.push({ startLine: i, endLine: end });
- }
- }
- return results;
- }
-
- // ==================== Text Mutation ====================
-
- function spliceLines(startLine, deleteCount, newText) {
- const lines = getLines();
- const newLines =
- newText && newText.length > 0
- ? (newText.endsWith("\n") ? newText.slice(0, -1) : newText).split("\n")
- : [];
- lines.splice(startLine, deleteCount, ...newLines);
- rawCandoText = lines.join("\n");
- hasEdits = true;
- }
-
- function markEdited(key) {
- if (key) editedKeys.add(key);
- }
-
- function isEdited(key) {
- return !!key && editedKeys.has(key);
- }
-
- function markNew(key) {
- if (key) newKeys.add(key);
- }
-
- function isNew(key) {
- return !!key && newKeys.has(key);
- }
-
- function resetEditState() {
- hasEdits = false;
- editedKeys.clear();
- newKeys.clear();
- }
-
- function deleteLineRange(startLine, endLine) {
- spliceLines(startLine, endLine - startLine, null);
- }
-
- function replaceLineRange(startLine, endLine, newText) {
- spliceLines(startLine, endLine - startLine, newText);
- }
-
- function insertAtLine(lineIdx, newText) {
- spliceLines(lineIdx, 0, newText);
- }
-
- function getLineRangeText(startLine, endLine) {
- const lines = getLines();
- const body = lines.slice(startLine, endLine).join("\n");
- return body ? body + "\n" : "";
- }
-
- function routeEntryExists(
- deviceName,
- busPort,
- receiver,
- msgName,
- canIdOverride,
- ) {
- const busRange = findRoutingBusRange(deviceName, busPort);
- if (!busRange) return false;
- const lines = getLines();
- let curReceiver = null;
- for (let i = busRange.startLine + 1; i < busRange.endLine; i++) {
- const indent = lines[i].search(/\S/);
- const content = lines[i].trim();
- if (indent === 8) curReceiver = content.replace(/:$/, "");
- if (
- indent === 10 &&
- content === "- msg: " + msgName &&
- curReceiver === receiver
- ) {
- const next = lines[i + 1] || "";
- const hasOverride =
- next.search(/\S/) === 12 &&
- next.trim().startsWith("can_id_override:");
- if (!canIdOverride && !hasOverride) return true;
- if (canIdOverride && hasOverride) {
- const ov = next.split(":")[1].trim();
- if (ov === canIdOverride) return true;
- }
- }
- }
- return false;
- }
-
- function getMessageIdEntries() {
- const lines = getLines();
- const start = lines.findIndex((l) => l.startsWith("Message ID:"));
- if (start === -1) return [];
- const end = findSectionEnd(lines, start);
- const entries = [];
- let cur = null;
- for (let i = start + 1; i < end; i++) {
- const raw = lines[i];
- const indent = raw.search(/\S/);
- if (indent === -1) continue;
- const content = raw.trim();
- if (indent === 2 && content.endsWith(":")) {
- cur = { name: content.slice(0, -1), msgId: null };
- entries.push(cur);
- continue;
- }
- if (cur && indent === 4 && content.startsWith("MSG ID:")) {
- cur.msgId = content.slice("MSG ID:".length).trim().toLowerCase();
- }
- }
- return entries;
- }
-
- function getGrIdEntries() {
- const lines = getLines();
- const start = lines.findIndex((l) => l.startsWith("GR ID:"));
- if (start === -1) return [];
- const end = findSectionEnd(lines, start);
- const entries = [];
- for (let i = start + 1; i < end; i++) {
- const raw = lines[i];
- const indent = raw.search(/\S/);
- if (indent !== 2) continue;
- const content = raw.trim();
- const colonIdx = content.indexOf(":");
- if (colonIdx <= 0) continue;
- const name = content.slice(0, colonIdx).trim();
- if (!name) continue;
- const idMatch = content.match(/:\s*["']?([^"'\s]+)["']?/);
- entries.push({ name, id: idMatch ? idMatch[1] : null, line: i });
- }
- return entries;
- }
-
- function findGrIdEntryRange(nodeName) {
- if (!nodeName) return null;
- const lines = getLines();
- const start = lines.findIndex((l) => l.startsWith("GR ID:"));
- if (start === -1) return null;
- const end = findSectionEnd(lines, start);
- for (let i = start + 1; i < end; i++) {
- const raw = lines[i];
- const indent = raw.search(/\S/);
- if (indent !== 2) continue;
- const content = raw.trim();
- const colonIdx = content.indexOf(":");
- if (colonIdx <= 0) continue;
- const name = content.slice(0, colonIdx).trim();
- if (name === nodeName) {
- return { startLine: i, endLine: i + 1 };
- }
- }
- return null;
- }
-
- function grIdNameExists(nodeName) {
- return !!findGrIdEntryRange(nodeName);
- }
-
- function renameGrIdNode(oldName, newName) {
- if (!oldName || !newName || oldName === newName) return false;
- const range = findGrIdEntryRange(oldName);
- if (!range) return false;
- const lines = getLines();
- const raw = lines[range.startLine];
- const colonIdx = raw.indexOf(":");
- if (colonIdx === -1) return false;
- const leadingWs = raw.match(/^\s*/);
- const prefix = leadingWs ? leadingWs[0] : "";
- const rest = raw.slice(colonIdx);
- const nextLine = prefix + newName + rest;
- if (nextLine === raw) return false;
- lines[range.startLine] = nextLine;
- rawCandoText = lines.join("\n");
- hasEdits = true;
- return true;
- }
-
- function messageNameExists(msgName) {
- return !!findMessageDefRange(msgName) || !!findCustomCanIdRange(msgName);
- }
-
- function isCustomCanIdMessage(msgName) {
- return !!findCustomCanIdRange(msgName);
- }
-
- function renameRoutingMessageRefs(oldName, newName) {
- if (!oldName || !newName || oldName === newName) return false;
- const lines = getLines();
- let changed = false;
- for (let i = 0; i < lines.length; i++) {
- if (
- lines[i].search(/\S/) === 10 &&
- lines[i].trim() === "- msg: " + oldName
- ) {
- lines[i] = lines[i].replace("- msg: " + oldName, "- msg: " + newName);
- changed = true;
- }
- }
- if (changed) {
- rawCandoText = lines.join("\n");
- hasEdits = true;
- }
- return changed;
- }
-
- // ==================== YAML Generators ====================
-
- function generateMessageIdYaml(data) {
- let y = " " + data.name + ":\n";
- y += " MSG ID: " + data.msgId + "\n";
- y += " MSG LENGTH: " + data.msgLength + "\n";
- for (const f of data.fields) {
- y += " " + f.name + ":\n";
- y += " bit_start: " + f.bitStart + "\n";
- if (f.comment) {
- f.comment.split("\n").forEach((line) => {
- y += " # " + line.trim() + "\n";
- });
- }
- y += " data type: " + f.dataType + "\n";
- if (f.units) y += " units: " + f.units + "\n";
- if (f.scaledMin !== undefined && f.scaledMin !== "")
- y += " scaled min: " + f.scaledMin + "\n";
- if (f.scaledMax !== undefined && f.scaledMax !== "")
- y += " scaled max: " + f.scaledMax + "\n";
- if (f.mapEquation) y += ' map equation: "' + f.mapEquation + '"\n';
- }
- return y;
- }
-
- function generateRoutingMsgYaml(msgName, canIdOverride) {
- let y = " - msg: " + msgName + "\n";
- if (canIdOverride)
- y += " can_id_override: " + canIdOverride + "\n";
- return y;
- }
-
- // ==================== Re-render ====================
-
- function triggerReRender() {
- if (typeof reRenderCallback === "function") {
- const snapshot = pendingNavSnapshot;
- pendingNavSnapshot = null;
- reRenderCallback(snapshot);
- }
- }
-
- // ==================== Icon / Button Creation ====================
- // These create the small inline edit/delete/add buttons injected into viewer list
- // items. SVG strings are sourced from FormUtils to avoid duplication.
-
- function createEditBtn(onClick) {
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "editor-icon-btn editor-icon-edit";
- btn.innerHTML = window.FormUtils.PENCIL_SVG;
- btn.title = "Edit";
- btn.setAttribute("aria-label", "Edit");
- btn.addEventListener("click", (e) => {
- e.stopPropagation();
- onClick();
- });
- return btn;
- }
-
- function createDeleteBtn(onClick) {
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "editor-icon-btn editor-icon-delete";
- btn.innerHTML = window.FormUtils.TRASH_SVG;
- btn.title = "Delete";
- btn.setAttribute("aria-label", "Delete");
- btn.addEventListener("click", (e) => {
- e.stopPropagation();
- onClick();
- });
- return btn;
- }
-
- function createAddBtn(label, onClick) {
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "editor-add-btn";
- btn.innerHTML = window.FormUtils.PLUS_SVG + " " + label;
- btn.addEventListener("click", (e) => {
- e.stopPropagation();
- onClick();
- });
- return btn;
- }
-
- // ==================== Download ====================
-
- function downloadCando() {
- const serialized = window.GrcanDocument
- ? window.GrcanDocument.getSerializedText()
- : null;
- const blob = new Blob([serialized ?? rawCandoText], { type: "text/plain" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "GRCAN.CANdo";
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
- }
-
- // ==================== Public API ====================
- // Form methods (showMessageEditForm, showRoutingAddForm, showRoutingNodeEditForm,
- // showRoutingBusEditForm, confirmAndDelete) are added by their respective form*.js
- // files after this object is created.
-
- window.GrcanEditor = {
- toggleEditMode() {
- editMode = !editMode;
- document.body.classList.toggle("edit-mode", editMode);
- return editMode;
- },
- isEditMode() {
- return editMode;
- },
- setRawText(text) {
- rawCandoText = text;
- originalRawCandoText = text;
- hasEdits = false;
- editedKeys.clear();
- newKeys.clear();
- },
- // Update working text without resetting original or edit state.
- // Used by GrcanDocument after semantic mutations.
- updateRawText(text) {
- rawCandoText = text;
- hasEdits = true;
- },
- getRawText() {
- return rawCandoText;
- },
- getOriginalRawText() {
- return originalRawCandoText;
- },
- hasUnsavedEdits() {
- return hasEdits;
- },
- setReRenderCallback(cb) {
- reRenderCallback = cb;
- },
- setNavSnapshot(snapshot) {
- pendingNavSnapshot = snapshot || null;
- },
- markEdited,
- isEdited,
- markNew,
- isNew,
- resetEditState,
- downloadCando,
- triggerReRender,
- createEditBtn,
- createDeleteBtn,
- createAddBtn,
- // Line-range accessors used by form files and viewer delete handlers:
- getLines,
- findSectionStart,
- findSectionEnd,
- findBlockEnd,
- findMessageDefRange,
- findRoutingDeviceRange,
- findRoutingBusRange,
- findRoutingMsgEntries,
- deleteLineRange,
- replaceLineRange,
- insertAtLine,
- getLineRangeText,
- routeEntryExists,
- getMessageIdEntries,
- getGrIdEntries,
- findGrIdEntryRange,
- grIdNameExists,
- renameGrIdNode,
- messageNameExists,
- isCustomCanIdMessage,
- renameRoutingMessageRefs,
- generateMessageIdYaml,
- generateRoutingMsgYaml,
- };
-})();
diff --git a/Web/formBusAdd.js b/Web/formBusAdd.js
deleted file mode 100644
index 340b4169e..000000000
--- a/Web/formBusAdd.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// Purpose: "Add Bus" modal form.
-// Allows creating a new bus block for an existing routing node without
-// requiring receiver/message route details. The list of available buses is
-// pulled from the "Bus ID:" section via GrcanDocument.getBusNames(),
-// so adding a new bus to the CANdo file requires no changes here.
-// Depends on: formUtils.js (FormUtils), editor.js (GrcanEditor).
-// Registers: window.GrcanEditor.showRoutingBusAddForm
-
-(function () {
- "use strict";
-
- function showRoutingBusAddForm(deviceName) {
- const editor = window.GrcanEditor;
- const fu = window.FormUtils;
- const { overlay, body, footer } = fu.createModal("Add Bus");
-
- const nodeF = fu.makeFormRow(
- "Node",
- fu.makeInput("text", deviceName || "", "Node Name"),
- true,
- );
- nodeF.input.disabled = true;
- body.appendChild(nodeF.row);
-
- // Filter the bus dropdown to only buses the device is physically wired to.
- // If the topology file isn't loaded yet, every declared bus is shown.
- const _allBuses = window.GrcanDocument.getBusNames();
- const _topo = window.PhysicalTopology;
- const _busChoices =
- deviceName && _topo && _topo.isLoaded()
- ? _allBuses.filter((b) => _topo.isOnBus(deviceName, b))
- : _allBuses;
- const _effectiveChoices = _busChoices.length > 0 ? _busChoices : _allBuses;
- const busF = fu.makeFormRow(
- "Bus",
- fu.makeSelect(_effectiveChoices, _effectiveChoices[0] || ""),
- true,
- );
- body.appendChild(busF.row);
-
- const cancelBtn = fu.makeBtn("Cancel");
- cancelBtn.addEventListener("click", () => fu.closeOverlay(overlay));
- const saveBtn = fu.makeBtn("Add", "editor-btn-primary");
- footer.appendChild(cancelBtn);
- footer.appendChild(saveBtn);
-
- saveBtn.addEventListener("click", () => {
- const bus = busF.input.value;
-
- // Hard-block: device must be physically wired to the selected bus.
- const topo = window.PhysicalTopology;
- if (topo && topo.isLoaded() && !topo.isOnBus(deviceName, bus)) {
- busF.error.textContent = `"${deviceName}" is not physically wired to ${bus}`;
- return;
- }
-
- const result = window.GrcanDocument.addBus(deviceName, bus);
- if (!result.ok) {
- busF.error.textContent = result.error;
- return;
- }
- busF.error.textContent = "";
- editor.markEdited("routeNode:" + deviceName);
- editor.markNew("routeBus:" + deviceName + "|" + bus);
- fu.closeOverlay(overlay, { force: true });
- editor.triggerReRender();
- });
- }
-
- window.GrcanEditor.showRoutingBusAddForm = showRoutingBusAddForm;
-})();
diff --git a/Web/formBusEdit.js b/Web/formBusEdit.js
deleted file mode 100644
index 8e49afb32..000000000
--- a/Web/formBusEdit.js
+++ /dev/null
@@ -1,60 +0,0 @@
-// Purpose: "Edit Bus" modal form.
-// Allows changing the bus assigned to a routing bus block for a given device.
-// The list of choices is sourced dynamically from the "Bus ID:" section via
-// GrcanDocument.getBusNames(), so adding/renaming a bus there propagates
-// automatically. Prevents renaming to a port that the device already has.
-// A no-op save (same bus port) closes without marking any change.
-// Depends on: formUtils.js (FormUtils), editor.js (GrcanEditor).
-// Registers: window.GrcanEditor.showRoutingBusEditForm
-
-(function () {
- "use strict";
-
- function showRoutingBusEditForm(deviceName, oldBusPort) {
- const editor = window.GrcanEditor;
- const fu = window.FormUtils;
- const { overlay, body, footer } = fu.createModal("Edit Bus");
-
- const _allBuses = window.GrcanDocument.getBusNames();
- const busF = fu.makeFormRow(
- "Bus",
- fu.makeSelect(_allBuses, oldBusPort || _allBuses[0] || ""),
- true,
- );
- body.appendChild(busF.row);
-
- const cancelBtn = fu.makeBtn("Cancel");
- cancelBtn.addEventListener("click", () => fu.closeOverlay(overlay));
- const saveBtn = fu.makeBtn("Save", "editor-btn-primary");
- footer.appendChild(cancelBtn);
- footer.appendChild(saveBtn);
-
- saveBtn.addEventListener("click", () => {
- const newBus = busF.input.value;
- const range = editor.findRoutingBusRange(deviceName, oldBusPort);
- if (!range) return;
-
- if (newBus === oldBusPort) {
- fu.closeOverlay(overlay, { force: true });
- return;
- }
-
- if (editor.findRoutingBusRange(deviceName, newBus)) {
- busF.error.textContent = "Bus already exists for this node";
- return;
- }
- busF.error.textContent = "";
-
- editor.replaceLineRange(
- range.startLine,
- range.startLine + 1,
- " " + newBus + ":\n",
- );
- editor.markEdited("routeBus:" + deviceName + "|" + newBus);
- fu.closeOverlay(overlay, { force: true });
- editor.triggerReRender();
- });
- }
-
- window.GrcanEditor.showRoutingBusEditForm = showRoutingBusEditForm;
-})();
diff --git a/Web/formConfirmDelete.js b/Web/formConfirmDelete.js
deleted file mode 100644
index 919bbe7b3..000000000
--- a/Web/formConfirmDelete.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Purpose: "Confirm Delete" modal dialog.
-// Generic confirmation prompt used before any destructive in-memory deletion.
-// Calls the provided deleteFn only when the user explicitly confirms, then
-// triggers a re-render. Reminds the user that changes are in-memory only.
-// Depends on: formUtils.js (FormUtils), editor.js (GrcanEditor).
-// Registers: window.GrcanEditor.confirmAndDelete
-
-(function () {
- "use strict";
-
- function confirmAndDelete(itemDesc, deleteFn) {
- const editor = window.GrcanEditor;
- const fu = window.FormUtils;
- const { overlay, body, footer } = fu.createModal("Confirm Delete");
-
- const msg = document.createElement("p");
- msg.className = "editor-confirm-msg";
- msg.textContent = 'Delete "' + itemDesc + '"?';
- body.appendChild(msg);
-
- const warn = document.createElement("p");
- warn.className = "editor-confirm-warn";
- warn.textContent =
- "This modifies the CANdo file in memory. Download to save.";
- body.appendChild(warn);
-
- const cancelBtn = fu.makeBtn("Cancel");
- cancelBtn.addEventListener("click", () => fu.closeOverlay(overlay));
- const delBtn = fu.makeBtn("Delete", "editor-btn-danger");
- footer.appendChild(cancelBtn);
- footer.appendChild(delBtn);
-
- delBtn.addEventListener("click", () => {
- deleteFn();
- fu.closeOverlay(overlay, { force: true });
- editor.triggerReRender();
- });
- }
-
- window.GrcanEditor.confirmAndDelete = confirmAndDelete;
-})();
diff --git a/Web/formCustomCanId.js b/Web/formCustomCanId.js
deleted file mode 100644
index c26364245..000000000
--- a/Web/formCustomCanId.js
+++ /dev/null
@@ -1,204 +0,0 @@
-// Purpose: "Add / Edit Custom CAN ID" modal form.
-// Handles Custom CAN ID section entries: CAN ID (hex), Length, and signals
-// (name, bit_start, optional comment). Uses GrcanDocument for mutations.
-// Depends on: formUtils.js (FormUtils), editor.js (GrcanEditor), candoDocument.js.
-// Registers: window.GrcanEditor.showCustomCanIdEditForm
-
-(function () {
- "use strict";
-
- function showCustomCanIdEditForm(msgName, isNew) {
- const editor = window.GrcanEditor;
- const doc = window.GrcanDocument;
- const fu = window.FormUtils;
-
- const existing = isNew ? null : doc.getCustomCanIdDef(msgName);
- const { overlay, body, footer } = fu.createModal(
- isNew ? "Add Custom CAN ID" : "Edit: " + msgName,
- );
-
- const nameF = fu.makeFormRow(
- "Message Name",
- fu.makeInput("text", isNew ? "" : msgName),
- true,
- );
- const idF = fu.makeFormRow(
- "CAN ID",
- fu.makeInput("text", existing?.canId || "", "e.g. 116 or 18FF50E5"),
- true,
- );
- const lenF = fu.makeFormRow(
- "Length (bytes)",
- fu.makeInput("number", existing?.length || "", "8"),
- true,
- );
- body.appendChild(nameF.row);
- body.appendChild(idF.row);
- body.appendChild(lenF.row);
-
- // Signals section
- const sigHdr = document.createElement("div");
- sigHdr.className = "editor-section-header";
- const sigTitle = document.createElement("span");
- sigTitle.textContent = "Signals";
- sigHdr.appendChild(sigTitle);
- const addSigBtn = document.createElement("button");
- addSigBtn.className = "editor-btn editor-btn-sm";
- addSigBtn.innerHTML = fu.PLUS_SVG + " Add Signal";
- sigHdr.appendChild(addSigBtn);
- body.appendChild(sigHdr);
-
- const sigBox = document.createElement("div");
- sigBox.className = "editor-fields-box";
- body.appendChild(sigBox);
-
- function addSignalRow(sig) {
- const card = document.createElement("div");
- card.className = "editor-field-card";
-
- const topRow = document.createElement("div");
- topRow.className = "editor-field-grid";
-
- const fName = fu.makeFormRow(
- "Name",
- fu.makeInput("text", sig?.name || "", "Signal Name"),
- true,
- );
- const fBit = fu.makeFormRow(
- "bit_start",
- fu.makeInput("text", sig?.bitStart || "", "0 or 0-7"),
- true,
- );
- topRow.appendChild(fName.row);
- topRow.appendChild(fBit.row);
- card.appendChild(topRow);
-
- const commentRow = document.createElement("div");
- commentRow.className = "editor-field-grid";
- const fComment = fu.makeFormRow(
- "Comment",
- fu.makeInput("textarea", sig?.comment || "", "Description"),
- );
- commentRow.appendChild(fComment.row);
- card.appendChild(commentRow);
-
- const controlsRow = document.createElement("div");
- controlsRow.className = "editor-field-controls";
- const upBtn = fu.makeBtn("Move Up", "editor-btn-sm");
- const downBtn = fu.makeBtn("Move Down", "editor-btn-sm");
- const removeBtn = document.createElement("button");
- removeBtn.className = "editor-btn editor-btn-danger editor-btn-sm";
- removeBtn.innerHTML = fu.TRASH_SVG + " Remove";
- removeBtn.addEventListener("click", () => card.remove());
- upBtn.addEventListener("click", (e) => {
- e.preventDefault();
- const prev = card.previousElementSibling;
- if (prev) card.parentNode.insertBefore(card, prev);
- });
- downBtn.addEventListener("click", (e) => {
- e.preventDefault();
- const next = card.nextElementSibling;
- if (next) card.parentNode.insertBefore(next, card);
- });
- controlsRow.appendChild(upBtn);
- controlsRow.appendChild(downBtn);
- controlsRow.appendChild(removeBtn);
- card.appendChild(controlsRow);
-
- card._getValues = () => ({
- name: fName.input.value.trim(),
- bitStart: fBit.input.value.trim(),
- comment: fComment.input.value.trim() || null,
- });
-
- card._validate = () => {
- let ok = true;
- if (!fName.input.value.trim()) {
- fName.error.textContent = "Required";
- ok = false;
- } else fName.error.textContent = "";
-
- const bs = fBit.input.value.trim();
- if (!bs) {
- fBit.error.textContent = "Required";
- ok = false;
- } else if (!/^\d+(\s*-\s*\d+)?$/.test(bs)) {
- fBit.error.textContent = "Integer or range (e.g. 0-7)";
- ok = false;
- } else fBit.error.textContent = "";
-
- return ok;
- };
-
- sigBox.appendChild(card);
- }
-
- if (existing && existing.signals.length > 0) {
- existing.signals.forEach((s) => addSignalRow(s));
- }
- addSigBtn.addEventListener("click", () => addSignalRow(null));
-
- const cancelBtn = fu.makeBtn("Cancel");
- cancelBtn.addEventListener("click", () => fu.closeOverlay(overlay));
- const saveBtn = fu.makeBtn("Save", "editor-btn-primary");
- footer.appendChild(cancelBtn);
- footer.appendChild(saveBtn);
-
- saveBtn.addEventListener("click", () => {
- let ok = true;
-
- const name = nameF.input.value.trim();
- if (!name) {
- nameF.error.textContent = "Required";
- ok = false;
- } else nameF.error.textContent = "";
-
- const canId = idF.input.value.trim();
- if (!canId) {
- idF.error.textContent = "Required";
- ok = false;
- } else if (!/^[0-9a-fA-F]+$/.test(canId)) {
- idF.error.textContent = "Must be hex (e.g. 116 or 18FF50E5)";
- ok = false;
- } else idF.error.textContent = "";
-
- const length = lenF.input.value.trim();
- if (!length || parseInt(length, 10) < 0 || isNaN(parseInt(length, 10))) {
- lenF.error.textContent = "Non-negative integer";
- ok = false;
- } else lenF.error.textContent = "";
-
- const cards = sigBox.querySelectorAll(".editor-field-card");
- cards.forEach((c) => {
- if (!c._validate()) ok = false;
- });
- if (!ok) return;
-
- const signals = [];
- cards.forEach((c) => signals.push(c._getValues()));
-
- const def = { name, canId, length, signals };
-
- if (!isNew && msgName) {
- const result = doc.updateCustomCanIdDef(msgName, def);
- if (!result.ok) {
- nameF.error.textContent = result.error;
- return;
- }
- editor.markEdited("customCan:" + msgName);
- if (name !== msgName) editor.markEdited("customCan:" + name);
- } else {
- const result = doc.addCustomCanIdDef(def);
- if (!result.ok) {
- nameF.error.textContent = result.error;
- return;
- }
- editor.markNew("customCan:" + name);
- }
- fu.closeOverlay(overlay, { force: true });
- editor.triggerReRender();
- });
- }
-
- window.GrcanEditor.showCustomCanIdEditForm = showCustomCanIdEditForm;
-})();
diff --git a/Web/formMessageDef.js b/Web/formMessageDef.js
deleted file mode 100644
index 2381943c6..000000000
--- a/Web/formMessageDef.js
+++ /dev/null
@@ -1,490 +0,0 @@
-// Purpose: "Add / Edit Message Definition" modal form.
-// Handles the full lifecycle of the Message ID section entry: parsing the current
-// raw YAML into a pre-populated form, validating all fields (required, hex MSG ID,
-// positive length, bit layout, overlap checks), writing the result back via
-// GrcanEditor mutations, and renaming routing references on rename.
-// Depends on: formUtils.js (FormUtils), editor.js (GrcanEditor).
-// Registers: window.GrcanEditor.showMessageEditForm
-
-(function () {
- "use strict";
-
- const VALID_DATA_TYPES = [
- "b",
- "u4",
- "s4",
- "u8",
- "s8",
- "u16",
- "s16",
- "u32",
- "s32",
- "s",
- ];
-
- // ==================== Raw Text Parser ====================
- // Reads the current working text from GrcanEditor to pre-populate the form
- // when editing an existing message definition.
-
- function parseMessageFromRaw(msgName) {
- const editor = window.GrcanEditor;
- const range = editor.findMessageDefRange(msgName);
- if (!range) return null;
- const lines = editor.getLines().slice(range.startLine, range.endLine);
- const result = { name: msgName, msgId: "", msgLength: "", fields: [] };
- let cur = null;
- let _inComment = false;
-
- for (const line of lines) {
- const indent = line.search(/\S/);
- if (indent === -1) continue;
- const c = line.trim();
-
- if (indent === 4) {
- _inComment = false;
- if (c.startsWith("MSG ID:")) {
- result.msgId = c.slice(7).trim();
- } else if (c.startsWith("MSG LENGTH:")) {
- result.msgLength = c.slice(11).trim().replace(/,/g, "");
- } else if (c.endsWith(":")) {
- if (cur) result.fields.push(cur);
- cur = {
- fieldName: c.slice(0, -1),
- bitStart: null,
- bitEnd: null,
- rawDataType: "",
- comment: "",
- units: "",
- scaledMin: "",
- scaledMax: "",
- mapEquation: "",
- };
- }
- } else if (indent >= 6 && cur) {
- // Any indent-6 line starts a new field property; only deeper
- // lines are valid comment continuation lines.
- if (indent === 6) _inComment = false;
- if (c.startsWith("bit_start:")) {
- const v = c.slice(10).trim();
- const rm = v.match(/^(\d+)\s*-\s*(\d+)$/);
- if (rm) {
- cur.bitStart = parseInt(rm[1], 10);
- cur.bitEnd = parseInt(rm[2], 10);
- } else {
- const n = parseInt(v.replace(/,/g, ""), 10);
- if (!isNaN(n)) {
- cur.bitStart = n;
- cur.bitEnd = n;
- }
- }
- } else if (c.startsWith("comment:")) {
- const raw = c.slice("comment:".length).trim();
- const inline = raw === "|" || raw === ">" ? "" : raw;
- cur.comment = inline || "";
- _inComment = true;
- } else if (c.startsWith("#")) {
- // backward compat: old # format
- _inComment = false;
- const t = c.replace(/^#\s*/, "").trim();
- if (t) cur.comment = cur.comment ? cur.comment + "\n" + t : t;
- } else if (!_inComment && c.startsWith("data type:")) {
- const rawType = c.slice(10).trim();
- // Backward compatibility for older aliases while keeping
- // canonical signed type labels in the editor UI.
- if (rawType === "i16") cur.rawDataType = "s16";
- else if (rawType === "i32") cur.rawDataType = "s32";
- else cur.rawDataType = rawType;
- } else if (!_inComment && c.startsWith("units:")) {
- cur.units = c.slice(6).trim();
- } else if (!_inComment && c.startsWith("scaled min:")) {
- cur.scaledMin = c.slice(11).trim();
- } else if (!_inComment && c.startsWith("scaled max:")) {
- cur.scaledMax = c.slice(11).trim();
- } else if (!_inComment && c.startsWith("map equation:")) {
- cur.mapEquation = c
- .slice(13)
- .trim()
- .replace(/^["']|["']$/g, "");
- } else if (_inComment) {
- // continuation lines of the comment: block
- cur.comment = cur.comment ? cur.comment + "\n" + c : c;
- }
- }
- }
- if (cur) result.fields.push(cur);
- return result;
- }
-
- // ==================== Form ====================
-
- function showMessageEditForm(msgName, isNewMsg) {
- const editor = window.GrcanEditor;
- const fu = window.FormUtils;
-
- const existing = isNewMsg ? null : parseMessageFromRaw(msgName);
- const { overlay, body, footer } = fu.createModal(
- isNewMsg ? "Add Message Definition" : "Edit: " + msgName,
- );
-
- const nameF = fu.makeFormRow(
- "Message Name",
- fu.makeInput("text", isNewMsg ? "" : msgName),
- true,
- );
- const idF = fu.makeFormRow(
- "MSG ID",
- fu.makeInput("text", existing?.msgId || "", "0x000"),
- true,
- );
- const lenF = fu.makeFormRow(
- "MSG LENGTH (bytes)",
- fu.makeInput("number", existing?.msgLength || "", "8"),
- true,
- );
- body.appendChild(nameF.row);
- body.appendChild(idF.row);
- body.appendChild(lenF.row);
-
- const fieldsHdr = document.createElement("div");
- fieldsHdr.className = "editor-section-header";
- const fieldsTitle = document.createElement("span");
- fieldsTitle.textContent = "Fields";
- fieldsHdr.appendChild(fieldsTitle);
- const addFieldBtn = document.createElement("button");
- addFieldBtn.className = "editor-btn editor-btn-sm";
- addFieldBtn.innerHTML = fu.PLUS_SVG + " Add Field";
- fieldsHdr.appendChild(addFieldBtn);
- body.appendChild(fieldsHdr);
-
- const fieldsBox = document.createElement("div");
- fieldsBox.className = "editor-fields-box";
- body.appendChild(fieldsBox);
-
- function addFieldRow(field) {
- const card = document.createElement("div");
- card.className = "editor-field-card";
-
- const topRow = document.createElement("div");
- topRow.className = "editor-field-grid";
-
- const fName = fu.makeFormRow(
- "Name",
- fu.makeInput("text", field?.fieldName || "", "Field Name"),
- true,
- );
- const bitVal = field
- ? field.bitEnd !== null && field.bitEnd !== field.bitStart
- ? field.bitStart + "-" + field.bitEnd
- : field.bitStart !== null
- ? String(field.bitStart)
- : ""
- : "";
- const fBit = fu.makeFormRow(
- "bit_start",
- fu.makeInput("text", bitVal, "0 or 0-7"),
- true,
- );
- const fType = fu.makeFormRow(
- "Data Type",
- fu.makeSelect(VALID_DATA_TYPES, field?.rawDataType || "u8"),
- true,
- );
- topRow.appendChild(fName.row);
- topRow.appendChild(fBit.row);
- topRow.appendChild(fType.row);
- card.appendChild(topRow);
-
- const commentRow = document.createElement("div");
- commentRow.className = "editor-field-comment-row";
- const fComment = fu.makeFormRow(
- "Comment",
- fu.makeInput("textarea", field?.comment || "", "Description"),
- );
- commentRow.appendChild(fComment.row);
- card.appendChild(commentRow);
-
- const botRow = document.createElement("div");
- botRow.className = "editor-field-grid editor-field-grid-4";
- const fUnits = fu.makeFormRow(
- "Units",
- fu.makeInput("text", field?.units || "", "e.g. Volts"),
- );
- const fMin = fu.makeFormRow(
- "Scaled Min",
- fu.makeInput("text", field?.scaledMin || ""),
- );
- const fMax = fu.makeFormRow(
- "Scaled Max",
- fu.makeInput("text", field?.scaledMax || ""),
- );
- const fEq = fu.makeFormRow(
- "Map Equation",
- fu.makeInput("text", field?.mapEquation || "", "e.g. 0.01x"),
- );
- botRow.appendChild(fUnits.row);
- botRow.appendChild(fMin.row);
- botRow.appendChild(fMax.row);
- botRow.appendChild(fEq.row);
- card.appendChild(botRow);
-
- const removeBtn = document.createElement("button");
- removeBtn.className = "editor-btn editor-btn-danger editor-btn-sm";
- removeBtn.innerHTML = fu.TRASH_SVG + " Remove";
- removeBtn.addEventListener("click", () => card.remove());
-
- const controlsRow = document.createElement("div");
- controlsRow.className = "editor-field-controls";
- const upBtn = fu.makeBtn("Move Up", "editor-btn-sm");
- const downBtn = fu.makeBtn("Move Down", "editor-btn-sm");
- upBtn.addEventListener("click", (e) => {
- e.preventDefault();
- const parent = card.parentNode;
- if (!parent) return;
- const prev = card.previousElementSibling;
- if (prev) parent.insertBefore(card, prev);
- });
- downBtn.addEventListener("click", (e) => {
- e.preventDefault();
- const parent = card.parentNode;
- if (!parent) return;
- const next = card.nextElementSibling;
- if (next) parent.insertBefore(next, card);
- });
- controlsRow.appendChild(upBtn);
- controlsRow.appendChild(downBtn);
- controlsRow.appendChild(removeBtn);
- card.appendChild(controlsRow);
-
- card._getValues = () => ({
- name: fName.input.value.trim(),
- bitStart: fBit.input.value.trim(),
- dataType: fType.input.value,
- comment: fComment.input.value.trim(),
- units: fUnits.input.value.trim(),
- scaledMin: fMin.input.value.trim(),
- scaledMax: fMax.input.value.trim(),
- mapEquation: fEq.input.value.trim(),
- });
-
- card._validate = () => {
- let ok = true;
- if (!fName.input.value.trim()) {
- fName.error.textContent = "Required";
- ok = false;
- } else fName.error.textContent = "";
-
- const bs = fBit.input.value.trim();
- if (!bs) {
- fBit.error.textContent = "Required";
- ok = false;
- } else if (!/^\d+(\s*-\s*\d+)?$/.test(bs)) {
- fBit.error.textContent = "Integer or range (e.g. 0-7)";
- ok = false;
- } else fBit.error.textContent = "";
-
- const units = fUnits.input.value.trim();
- if (units && (units.length > 32 || /[:\n\r]/.test(units))) {
- fUnits.error.textContent = "Invalid units format";
- ok = false;
- } else fUnits.error.textContent = "";
-
- const minRaw = fMin.input.value.trim();
- const maxRaw = fMax.input.value.trim();
- const minVal = minRaw ? fu.parseNumericText(minRaw) : null;
- const maxVal = maxRaw ? fu.parseNumericText(maxRaw) : null;
- if (minRaw && minVal === null) {
- fMin.error.textContent = "Must be numeric";
- ok = false;
- } else fMin.error.textContent = "";
- if (maxRaw && maxVal === null) {
- fMax.error.textContent = "Must be numeric";
- ok = false;
- } else fMax.error.textContent = "";
- if (minVal !== null && maxVal !== null && minVal > maxVal) {
- fMax.error.textContent = "Must be >= scaled min";
- ok = false;
- }
-
- const eq = fEq.input.value.trim();
- if (eq && !fu.isValidMapEquation(eq)) {
- fEq.error.textContent = "Invalid equation format";
- ok = false;
- } else fEq.error.textContent = "";
- return ok;
- };
-
- fieldsBox.appendChild(card);
- }
-
- if (existing && existing.fields.length > 0) {
- existing.fields.forEach((f) => addFieldRow(f));
- }
- addFieldBtn.addEventListener("click", () => addFieldRow(null));
-
- const cancelBtn = fu.makeBtn("Cancel");
- cancelBtn.addEventListener("click", () => fu.closeOverlay(overlay));
- const saveBtn = fu.makeBtn("Save", "editor-btn-primary");
- footer.appendChild(cancelBtn);
- footer.appendChild(saveBtn);
-
- saveBtn.addEventListener("click", () => {
- let ok = true;
-
- const name = nameF.input.value.trim();
- if (!name) {
- nameF.error.textContent = "Required";
- ok = false;
- } else nameF.error.textContent = "";
-
- const msgId = idF.input.value.trim();
- if (!msgId) {
- idF.error.textContent = "Required";
- ok = false;
- } else if (!/^0x[0-9a-fA-F]+$/.test(msgId)) {
- idF.error.textContent = "Hex format (e.g. 0x003)";
- ok = false;
- } else idF.error.textContent = "";
-
- const existingDefs = editor.getMessageIdEntries();
- const existingName = existingDefs.find(
- (e) => e.name === name && (isNewMsg || e.name !== msgName),
- );
- if (existingName) {
- nameF.error.textContent = "Message name already exists";
- ok = false;
- }
- const msgIdTakenBy = existingDefs.find(
- (e) =>
- e.msgId === msgId.toLowerCase() && (isNewMsg || e.name !== msgName),
- );
- if (msgIdTakenBy) {
- idF.error.textContent = "MSG ID already used by " + msgIdTakenBy.name;
- ok = false;
- }
-
- const msgLen = lenF.input.value.trim();
- if (!msgLen || parseInt(msgLen, 10) <= 0) {
- lenF.error.textContent = "Positive integer";
- ok = false;
- } else lenF.error.textContent = "";
-
- const cards = fieldsBox.querySelectorAll(".editor-field-card");
- cards.forEach((c) => {
- if (!c._validate()) ok = false;
- });
- if (!ok) return;
-
- const fields = [];
- cards.forEach((c) => fields.push(c._getValues()));
- lenF.error.textContent = "";
- const msgLenBits = parseInt(msgLen, 10) * 8;
- const spans = [];
- for (let i = 0; i < fields.length; i++) {
- const f = fields[i];
- const rawBit = String(f.bitStart || "").trim();
- const range = rawBit.match(/^(\d+)\s*-\s*(\d+)$/);
- const start = range ? parseInt(range[1], 10) : parseInt(rawBit, 10);
- const explicitEnd = range ? parseInt(range[2], 10) : null;
- const width = fu.typeBitWidth(f.dataType);
- if (!Number.isInteger(start) || start < 0) {
- lenF.error.textContent = "Invalid bit layout: negative/invalid start";
- ok = false;
- break;
- }
- let end;
- if (f.dataType === "s") {
- if (explicitEnd !== null) {
- lenF.error.textContent = "String field must use single bit_start";
- ok = false;
- break;
- }
- if (start % 8 !== 0) {
- lenF.error.textContent =
- "String field bit_start should be byte-aligned";
- ok = false;
- break;
- }
- end = msgLenBits - 1;
- } else {
- if (!width) {
- lenF.error.textContent = "Unsupported data type in field layout";
- ok = false;
- break;
- }
- if (explicitEnd !== null) {
- if (explicitEnd < start) {
- lenF.error.textContent = "Invalid bit range in field " + f.name;
- ok = false;
- break;
- }
- const spanWidth = explicitEnd - start + 1;
- if (spanWidth !== width) {
- lenF.error.textContent =
- "Field " +
- f.name +
- " bit range width must match data type width";
- ok = false;
- break;
- }
- end = explicitEnd;
- } else {
- end = start + width - 1;
- }
- }
- if (end >= msgLenBits) {
- lenF.error.textContent =
- "Field " + f.name + " exceeds MSG LENGTH (" + msgLen + " bytes)";
- ok = false;
- break;
- }
- spans.push({ start, end, name: f.name, type: f.dataType });
- }
-
- if (ok) {
- for (let i = 0; i < spans.length; i++) {
- for (let j = i + 1; j < spans.length; j++) {
- if (
- spans[i].type !== "s" &&
- spans[j].type !== "s" &&
- spans[i].start <= spans[j].end &&
- spans[j].start <= spans[i].end
- ) {
- lenF.error.textContent =
- "Field overlap: " + spans[i].name + " and " + spans[j].name;
- ok = false;
- break;
- }
- }
- if (!ok) break;
- }
- }
- if (!ok) return;
-
- const def = { name, msgId, msgLength: msgLen, fields };
-
- if (!isNewMsg && msgName) {
- const result = window.GrcanDocument.updateMessageDef(msgName, def);
- if (!result.ok) {
- nameF.error.textContent = result.error;
- return;
- }
- editor.markEdited("msgDef:" + msgName);
- if (name !== msgName) editor.markEdited("msgDef:" + name);
- fu.closeOverlay(overlay, { force: true });
- editor.triggerReRender();
- return;
- } else {
- const result = window.GrcanDocument.addMessageDef(def);
- if (!result.ok) {
- nameF.error.textContent = result.error;
- return;
- }
- editor.markNew("msgDef:" + name);
- }
- fu.closeOverlay(overlay, { force: true });
- editor.triggerReRender();
- });
- }
-
- window.GrcanEditor.showMessageEditForm = showMessageEditForm;
-})();
diff --git a/Web/formNodeEdit.js b/Web/formNodeEdit.js
deleted file mode 100644
index 96951e15c..000000000
--- a/Web/formNodeEdit.js
+++ /dev/null
@@ -1,104 +0,0 @@
-// Purpose: "Edit Node" modal form.
-// Allows editing a routing device/node entry in-place: its name and its GR ID
-// (hex node ID). Validates that the new name is non-empty and does not collide
-// with an existing node, and that the GR ID is hex and not already used by
-// another node. A no-op save (name and ID unchanged) closes without marking any
-// change.
-// Depends on: formUtils.js (FormUtils), editor.js (GrcanEditor), candoDocument.js.
-// Registers: window.GrcanEditor.showRoutingNodeEditForm
-
-(function () {
- "use strict";
-
- function showRoutingNodeEditForm(oldDeviceName) {
- const editor = window.GrcanEditor;
- const fu = window.FormUtils;
- const { overlay, body, footer } = fu.createModal("Edit Node");
-
- const oldGrId =
- (window.GrcanDocument && window.GrcanDocument.getGrId(oldDeviceName)) ||
- "";
-
- const nameF = fu.makeFormRow(
- "Node Name",
- fu.makeInput("text", oldDeviceName || "", "Node Name"),
- true,
- );
- const idF = fu.makeFormRow(
- "Node ID (GR ID)",
- fu.makeInput("text", oldGrId, "0x2B"),
- true,
- );
- body.appendChild(nameF.row);
- body.appendChild(idF.row);
-
- const cancelBtn = fu.makeBtn("Cancel");
- cancelBtn.addEventListener("click", () => fu.closeOverlay(overlay));
- const saveBtn = fu.makeBtn("Save", "editor-btn-primary");
- footer.appendChild(cancelBtn);
- footer.appendChild(saveBtn);
-
- saveBtn.addEventListener("click", () => {
- const newName = nameF.input.value.trim();
- const newId = idF.input.value.trim();
- let ok = true;
-
- if (!newName) {
- nameF.error.textContent = "Required";
- ok = false;
- } else if (
- newName !== oldDeviceName &&
- window.GrcanDocument.deviceExists(newName)
- ) {
- nameF.error.textContent = "Node already exists";
- ok = false;
- } else {
- nameF.error.textContent = "";
- }
-
- if (!newId) {
- idF.error.textContent = "Required";
- ok = false;
- } else if (!/^0x[0-9a-fA-F]+$/.test(newId)) {
- idF.error.textContent = "Hex format (e.g. 0x2B)";
- ok = false;
- } else {
- idF.error.textContent = "";
- }
- if (!ok) return;
-
- const nameChanged = newName !== oldDeviceName;
- const idChanged = newId.toLowerCase() !== String(oldGrId).toLowerCase();
-
- if (!nameChanged && !idChanged) {
- fu.closeOverlay(overlay, { force: true });
- return;
- }
-
- if (nameChanged) {
- const result = window.GrcanDocument.renameDevice(
- oldDeviceName,
- newName,
- );
- if (!result.ok) {
- nameF.error.textContent = result.error;
- return;
- }
- }
-
- if (idChanged) {
- const result = window.GrcanDocument.updateGrId(newName, newId);
- if (!result.ok) {
- idF.error.textContent = result.error;
- return;
- }
- }
-
- editor.markEdited("routeNode:" + newName);
- fu.closeOverlay(overlay, { force: true });
- editor.triggerReRender();
- });
- }
-
- window.GrcanEditor.showRoutingNodeEditForm = showRoutingNodeEditForm;
-})();
diff --git a/Web/formRoutingAdd.js b/Web/formRoutingAdd.js
deleted file mode 100644
index 023eaa14d..000000000
--- a/Web/formRoutingAdd.js
+++ /dev/null
@@ -1,541 +0,0 @@
-// Purpose: "Add Route" modal form.
-// Lets users add a new routing entry (device / bus / receiver / message) to the
-// CANdo routing section, creating the device or bus block if they don't exist yet.
-// Validates that the named message exists in Message ID or Custom CAN ID, rejects
-// duplicate exact-match entries, and marks new/changed tracking keys accordingly.
-// Depends on: formUtils.js (FormUtils), editor.js (GrcanEditor).
-// Registers: window.GrcanEditor.showRoutingAddForm
-
-(function () {
- "use strict";
-
- function showRoutingAddForm(deviceName, busPort) {
- const editor = window.GrcanEditor;
- const fu = window.FormUtils;
- const { overlay, body, footer } = fu.createModal("Add Route");
-
- // Catalog candidates are loaded from parser outputs (headers) via GrcanApi.
- // Current routing usage is still derived from in-memory text for unused-first ranking.
- let allMessageNames = [];
- const hasRoute = new Map();
- let receiverList = []; // topology-filtered; drives the autocomplete dropdown
- let _fullReceiverList = []; // all known nodes pre-filter; used for inline topology warning
-
- function routingSectionBounds(lines) {
- const routingStart = lines.findIndex((l) => l.startsWith("routing:"));
- if (routingStart === -1) return null;
- const routingEnd = lines.findIndex(
- (l, i) => i > routingStart + 1 && /^\S/.test(l),
- );
- return {
- start: routingStart,
- end: routingEnd === -1 ? lines.length : routingEnd,
- };
- }
-
- // busPortFilter: if provided (e.g. "Charger"), only marks messages as used
- // when they appear under that specific bus port block. This makes the
- // "unused first" ranking bus-local rather than global.
- function buildRouteUsageMap(rawText, busPortFilter) {
- hasRoute.clear();
- const lines = String(rawText || "").split("\n");
- const bounds = routingSectionBounds(lines);
- if (!bounds) return;
- let activeBus = !busPortFilter;
- for (let i = bounds.start + 1; i < bounds.end; i++) {
- const line = lines[i];
- if (!line.trim()) continue;
- const indent = line.search(/\S/);
- const content = line.trim();
- if (indent === 6) {
- // "