630eb30bc3695afcf73a19be6e3bc9a2829b365f
chmalee
  Wed May 13 13:04:37 2026 -0700
Make myVariants items bed12+ rather than bed9+, refs #33808

diff --git src/hg/js/hgTracks.js src/hg/js/hgTracks.js
index 44c60720167..2b1259d1692 100644
--- src/hg/js/hgTracks.js
+++ src/hg/js/hgTracks.js
@@ -966,30 +966,61 @@
         advancedToggleContainer.appendChild(advancedToggle);
         manualInpDiv.appendChild(advancedToggleContainer);
 
         // Advanced fields div (hidden by default)
         let advancedDiv = document.createElement("div");
         advancedDiv.id = "advancedFieldsDiv";
         advancedDiv.style.display = "none";
         advancedDiv.style.marginLeft = "20px";
         advancedDiv.style.borderLeft = "2px solid #ccc";
         advancedDiv.style.paddingLeft = "10px";
 
         advancedFields.forEach(field => {
             createField(field, advancedDiv);
         });
 
+        // Blocks (BED12) section. Empty rows means a single full-span block.
+        let blocksSection = document.createElement("div");
+        blocksSection.id = "blocksSection";
+        blocksSection.style.cssText = "margin-top:12px; padding-top:10px; border-top:1px solid #ddd;";
+        let blocksLabel = document.createElement("div");
+        blocksLabel.style.cssText = "font-weight:bold; margin-bottom:8px; font-size:13px;";
+        blocksLabel.textContent = "Blocks (optional)";
+        blocksSection.appendChild(blocksLabel);
+        let blocksHint = document.createElement("div");
+        blocksHint.style.cssText = "font-size:12px; color:#666; margin-bottom:6px;";
+        blocksHint.textContent = "Offsets are relative to Start. First offset must be 0; " +
+                                 "last block must reach End. Leave empty for a single full-span block.";
+        blocksSection.appendChild(blocksHint);
+        let blocksContainer = document.createElement("div");
+        blocksContainer.id = "blocksContainer";
+        blocksSection.appendChild(blocksContainer);
+        // Hidden inputs that the widget keeps in sync. createItem reads them.
+        let hCount = document.createElement("input");
+        hCount.type = "hidden";
+        hCount.id = "blockCount";
+        let hSizes = document.createElement("input");
+        hSizes.type = "hidden";
+        hSizes.id = "blockSizes";
+        let hStarts = document.createElement("input");
+        hStarts.type = "hidden";
+        hStarts.id = "chromStarts";
+        blocksSection.appendChild(hCount);
+        blocksSection.appendChild(hSizes);
+        blocksSection.appendChild(hStarts);
+        advancedDiv.appendChild(blocksSection);
+
         // Custom fields section inside advanced fields
         let customFieldsSection = document.createElement("div");
         customFieldsSection.id = "customFieldsSection";
         customFieldsSection.style.cssText = "margin-top:12px; padding-top:10px; border-top:1px solid #ddd;";
 
         let customFieldsLabel = document.createElement("div");
         customFieldsLabel.style.cssText = "font-weight:bold; margin-bottom:8px; font-size:13px;";
         customFieldsLabel.textContent = "Custom Fields";
         customFieldsSection.appendChild(customFieldsLabel);
 
         let customFieldsList = document.createElement("div");
         customFieldsList.id = "customFieldsList";
         customFieldsSection.appendChild(customFieldsList);
 
         // Reserved field names that cannot be used as custom field names
@@ -1225,30 +1256,49 @@
 
         // Initialize Spectrum color picker after form is added to DOM
         if (colorInput) {
             $(colorInput).spectrum({
                 hideAfterPaletteSelect: true,
                 color: colorInput.value,
                 showPalette: true,
                 showInput: true,
                 showSelectionPalette: true,
                 showInitial: true,
                 preferredFormat: "hex",
                 localStorageKey: "myVariantsColors"
             });
         }
 
+        // Mount the block editor; reads Start/End from the chrom range fields.
+        // Pass the container element directly: at this point the form is in
+        // the dialog div but the dialog hasn't been appended to body yet, so
+        // getElementById would still miss "blocksContainer".
+        // Stash the widget handle on the form so createItem can call validate().
+        if (typeof myVariantsBlocks !== "undefined") {
+            form.blocksWidget = myVariantsBlocks.mount(blocksContainer, {
+                getStart: function () {
+                    return parseInt(document.getElementById("start").value, 10);
+                },
+                getEnd: function () {
+                    return parseInt(document.getElementById("end").value, 10);
+                },
+                hiddenCountInput: hCount,
+                hiddenSizesInput: hSizes,
+                hiddenStartsInput: hStarts
+            });
+        }
+
         return form;
     },
 
     init: function () {
         // show a jquery-ui dialog when a user clicks on the 'Add Annotation' button
         let dialog = document.getElementById('myVariantsDialog');
         if (!dialog) {
             dialog = document.createElement("div");
             dialog.id = "myVariantsDialog";
             dialog.style = "display: none";
 
             dialogButtons = {};
             // Call the function to build the form, but only if logged in already
             if (!userIsLoggedIn) {
                 let msg = document.createElement("div");
@@ -1263,67 +1313,90 @@
                     // extract the form elements and check
                     myVariants.createItem(form);
                 };
             }
             dialogButtons.Cancel = function(){
                 $(this).dialog("close");
             };
             $(dialog).dialog({
                 title: "My Annotations",
                 resizable: false,
                 height: "auto",
                 width: 580,
                 modal: true,
                 closeOnEscape: true,
                 autoOpen: false,
-                buttons: dialogButtons
+                buttons: dialogButtons,
+                close: function() {
+                    // Reset block rows so the next open starts clean.
+                    let form = document.getElementById("myVariants-form");
+                    if (form && form.blocksWidget) {
+                        form.blocksWidget.clear();
+                    }
+                }
             });
         } else {
             // got here after async image update, need to update the bed form coordinates
             let form = document.getElementById("myVariants-form");
             let start = form.elements.start;
             let end = form.elements.end;
             let thickStart = form.elements.thickStart;
             let thickEnd = form.elements.thickEnd;
             start.value = thickStart.value = hgTracks.winStart;
             end.value = thickEnd.value = hgTracks.winEnd;
             // Update the position summary to reflect new coordinates
             let summaryText = document.getElementById("positionSummaryText");
             if (summaryText) {
                 let chromEl = document.getElementById("chrom");
                 let startFmt = parseInt(hgTracks.winStart).toLocaleString();
                 let endFmt = parseInt(hgTracks.winEnd).toLocaleString();
                 summaryText.innerHTML = "<b>Position:</b> " + (chromEl ? chromEl.value : hgTracks.chromName) +
                     ":" + startFmt + "-" + endFmt + " <span style='color:#888'>(from current view)</span>";
             }
         }
 
         // if we clicked outside of the pop up, close the popup:
         document.addEventListener('click', (e) => {
             let dialogEl = document.getElementById("myVariantsDialog");
             if (!dialogEl) return;
+            // If the click handler that ran first removed its own target from
+            // the document (eg a row remove button, or a Grammarly/extension
+            // DOM swap on the description field), e.target is now detached
+            // and `.contains(e.target)` would falsely report "outside". Skip.
+            if (!document.contains(e.target)) return;
             let dialogContainer = dialogEl.parentElement;
             // Check if click target is inside the dialog (handles native dropdowns that render outside bounds)
             if (dialogContainer && !dialogContainer.contains(e.target)) {
                 $("#myVariantsDialog").dialog("close");
             }
         });
     },
 
     createItem: function(form) {
         // sends a post to hgTracks that adds a new item to the users custom track
         // and updates the image to include this track if it wasn't already there
+        // Strict block check now (server will re-check authoritatively).
+        // noBlocks means the user added a row but left every size empty;
+        // treat as no blocks so the server synthesizes a single full-span block.
+        let blockResult = {ok: true, noBlocks: true};
+        if (form.blocksWidget && form.blocksWidget.getRowCount() > 0) {
+            blockResult = form.blocksWidget.validate();
+            if (!blockResult.ok) {
+                alert("Block error: " + blockResult.msg);
+                return;
+            }
+        }
         const data = {};
         if (form.elements.hgvsInput.value) {
             data.hgvsInput = form.elements.hgvsInput.value;
         } else {
             Array.from(form.elements).forEach( (ele) => {
                 if (ele.name === "myVariantsHgvsInput" || ele.name === "hgt_doJsCommand" ||
                         (ele.tagName !== "INPUT" && ele.tagName !== "TEXTAREA")) {return;}
                 const key = ele.id;
                 let value = ele.value;
                 // Handle Spectrum color picker - get the value from spectrum if available
                 if (ele.id === "color" && $(ele).spectrum) {
                     let spectrumColor = $(ele).spectrum("get");
                     if (spectrumColor) {
                         value = spectrumColor.toHexString();
                     }
@@ -1333,30 +1406,44 @@
             // Collect custom fields from the dynamic rows
             let customRows = document.querySelectorAll("#customFieldsList .customFieldRow");
             if (customRows.length > 0) {
                 let customFields = [];
                 customRows.forEach(function(row) {
                     let name = row.querySelector(".customFieldName").value.trim();
                     if (name) {
                         let value = row.querySelector(".customFieldValue").value;
                         customFields.push({name: name, value: value});
                     }
                 });
                 if (customFields.length > 0) {
                     data.extraFields = customFields;
                 }
             }
+            // Convert hidden block fields from CSV strings to arrays of ints.
+            // Omit when blockCount is 0, or when the widget reported noBlocks
+            // (rows added but all sizes left empty); server then synthesizes
+            // a single full-span block.
+            let bc = parseInt(data.blockCount, 10);
+            if (!bc || blockResult.noBlocks) {
+                delete data.blockCount;
+                delete data.blockSizes;
+                delete data.chromStarts;
+            } else {
+                data.blockCount = bc;
+                data.blockSizes = data.blockSizes.split(",").map(Number);
+                data.chromStarts = data.chromStarts.split(",").map(Number);
+            }
         }
 
         // Show loading indicator
         const loadingId = showLoadingImage("imgTbl");
         document.body.style.cursor = "wait";
 
         // Build request - use fetch() instead of form.submit()
         const trackName = form.elements.namedItem("trackName").value;
         const req = encodeURIComponent(`myVariants ${trackName} ${JSON.stringify(data)}`);
         const url = cart.addUpdatesToUrl(`../cgi-bin/hgTracks?hgt_doJsCommand=${req}&trackName=${trackName}&hgt.trackImgOnly=1&hgt.ideogramToo=1&hgsid=${getHgsid()}&db=${getDb()}`);
 
         fetch(url, {
             method: "POST",
             credentials: "same-origin"
         })