Table Formatting - GUI Editing, Drag, Resize, etc

Just another quick one.
This one allows GUI editing of a table (rather than dropping to Markdown) and gives handles for re-arranging, resizing, etc.

Use at your own risk. Claude did this - I am not a programmer.

```space-style
/* Tables fill the editor width and wrap text instead of scrolling */
.sb-table-widget {
  overflow-x: visible !important;
}
.sb-table-widget table {
  width: 100% !important;
  table-layout: auto;
}
.sb-table-widget td,
.sb-table-widget th {
  white-space: normal !important;
  overflow-wrap: anywhere;
  word-break: break-word;
}
```
```space-style
/* Column resizing (script below): border-box keeps fixed-layout widths summing to 100% */
.sb-table-widget td,
.sb-table-widget th {
  box-sizing: border-box;
}
body.sb-col-resize-hover .sb-table-widget,
body.sb-col-resize-hover .sb-table-widget *,
body.sb-col-resizing,
body.sb-col-resizing * {
  cursor: col-resize !important;
}
body.sb-col-resizing {
  user-select: none;
}
```

# Resizable columns
* Drag a header cell's border to resize the two columns either side of it
* Double-click a header border to reset the table
* Widths are kept per page + header row in this browser's localStorage

```space-lua
local doc = js.window.document
if not doc.getElementById("sb-table-resize") then
  local s = doc.createElement("script")
  s.id = "sb-table-resize"
  s.innerHTML = [[
(function () {
  const KEY = "sb-table-widths", EDGE = 6, MIN = 40;
  let saved = {};
  try { saved = JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) {}
  const persist = () => { try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch (e) {} };

  const headCells = t => (t.rows.length ? Array.from(t.rows[0].cells) : []);
  const keyOf = t => decodeURIComponent(location.pathname) + "|" +
    headCells(t).map(c => c.textContent.trim()).join("|");

  function apply(t, pcts) {
    t.style.tableLayout = "fixed";
    headCells(t).forEach((c, i) => { c.style.width = pcts[i] + "%"; });
  }
  function reset(t) {
    t.style.tableLayout = "";
    headCells(t).forEach(c => { c.style.width = ""; });
  }
  function restore() {
    document.querySelectorAll(".sb-table-widget table").forEach(t => {
      const p = saved[keyOf(t)];
      if (p && p.length === headCells(t).length) apply(t, p);
    });
  }

  // { t, i } when the pointer is on the border between header cells i and i+1
  function edgeAt(e) {
    const cell = e.target.closest && e.target.closest(".sb-table-widget th, .sb-table-widget td");
    if (!cell) return null;
    const t = cell.closest("table");
    if (!t || cell.parentElement !== t.rows[0]) return null;
    const cells = headCells(t), idx = cells.indexOf(cell), r = cell.getBoundingClientRect();
    if (r.right - e.clientX <= EDGE && idx < cells.length - 1) return { t, i: idx };
    if (e.clientX - r.left <= EDGE && idx > 0) return { t, i: idx - 1 };
    return null;
  }

  let dragging = false, swallowClick = false;

  document.addEventListener("mousemove", e => {
    if (!dragging) document.body.classList.toggle("sb-col-resize-hover", !!edgeAt(e));
  }, { passive: true });

  // Keep CodeMirror from moving the cursor into the table source
  const block = e => {
    if (dragging || swallowClick || edgeAt(e)) { e.preventDefault(); e.stopPropagation(); }
  };
  document.addEventListener("mousedown", block, true);
  document.addEventListener("click", block, true);

  document.addEventListener("pointerdown", e => {
    if (e.button !== 0) return;
    const hit = edgeAt(e);
    if (!hit) return;
    e.preventDefault(); e.stopPropagation();
    const { t, i } = hit;
    const total = t.getBoundingClientRect().width;
    const px = headCells(t).map(c => c.getBoundingClientRect().width);
    const pct = () => px.map(w => +(w / total * 100).toFixed(2));
    const x0 = e.clientX, a = px[i], b = px[i + 1];
    apply(t, pct());
    dragging = true;
    document.body.classList.add("sb-col-resizing");
    const move = ev => {
      const d = Math.max(MIN - a, Math.min(b - MIN, ev.clientX - x0));
      px[i] = a + d; px[i + 1] = b - d;
      apply(t, pct());
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      dragging = false;
      document.body.classList.remove("sb-col-resizing");
      saved[keyOf(t)] = pct();
      persist();
      swallowClick = true;
      setTimeout(() => { swallowClick = false; }, 0);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up, { once: true });
  }, true);

  document.addEventListener("dblclick", e => {
    const hit = edgeAt(e);
    if (!hit) return;
    e.preventDefault(); e.stopPropagation();
    delete saved[keyOf(hit.t)];
    persist();
    reset(hit.t);
  }, true);

  // CodeMirror rebuilds table widgets on edit/scroll; re-apply saved widths
  let queued = false;
  new MutationObserver(() => {
    if (queued) return;
    queued = true;
    requestAnimationFrame(() => { queued = false; restore(); });
  }).observe(document.body, { childList: true, subtree: true });
  restore();
})();
]]
  doc.head.appendChild(s)
end
```

# Inline editing
* Click a cell to edit its markdown in place; Enter or clicking away saves, Esc cancels
* Tab / Shift+Tab save and move to the next / previous cell; Tab on the last cell adds a row
* Alt+click (or Ctrl+click) still opens the table source; links and checkboxes in cells work as before
* The ⋮ handle on a hovered cell inserts, moves and deletes rows and columns, and sets column alignment
* Drag the grip left of a row, or on top of a column header, to reorder; Esc cancels the drag
* Every change is one ordinary edit, so Ctrl+Z undoes it. Tables whose pipes are already aligned stay aligned; compact tables stay compact
* Only tables written in the page are editable; query and Lua output is left alone

```space-style
.sb-table-widget td,
.sb-table-widget th {
  cursor: text;
}
.sb-table-widget td a,
.sb-table-widget th a {
  cursor: pointer;
}
.sb-table-widget .sb-cell-editing {
  padding: 0 !important;
}
.sb-cell-editor {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin: 0;
  padding: 2px 4px;
  font: inherit;
  line-height: inherit;
  color: inherit;
  background: var(--root-background-color);
  border: 1px solid var(--ui-accent-color);
  border-radius: var(--ds-radius);
  outline: none;
  resize: none;
  overflow: hidden;
}
.sb-table-handle {
  position: fixed;
  z-index: 50;
  display: none;
  width: 18px;
  height: 18px;
  padding: 0;
  align-items: center;
  justify-content: center;
  color: var(--ds-muted);
  background: var(--ds-surface);
  border: 1px solid var(--ds-edge);
  border-radius: var(--ds-radius);
  cursor: pointer;
}
.sb-table-handle.is-visible {
  display: flex;
}
.sb-table-handle:hover {
  color: var(--root-color);
}
.sb-table-handle svg {
  width: 14px;
  height: 14px;
}
.sb-table-handle.is-grip {
  cursor: grab;
  touch-action: none;
}
.sb-table-handle.is-col svg {
  transform: rotate(90deg);
}
body.sb-table-dragging,
body.sb-table-dragging * {
  cursor: grabbing !important;
  user-select: none;
}
/* decoration, not text contrast: the row or column being carried */
.sb-table-widget .sb-table-dragged {
  opacity: 0.4;
}
.sb-table-drop {
  position: fixed;
  z-index: 55;
  pointer-events: none;
  background: var(--ui-accent-color);
}
.sb-table-menu {
  position: fixed;
  z-index: 60;
  min-width: 190px;
  padding: 4px 0;
  font-size: var(--ds-font-sm);
  color: var(--root-color);
  background: var(--ds-surface);
  border: 1px solid var(--ds-edge);
  border-radius: var(--ds-radius);
  box-shadow: var(--ds-elevation-2);
}
.sb-table-menu .sb-table-menu-head {
  padding: 6px 12px 2px;
  font-size: var(--ds-font-xs);
  color: var(--ds-muted);
}
.sb-table-menu button {
  display: block;
  width: 100%;
  padding: 4px 12px;
  text-align: left;
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
}
.sb-table-menu button:hover,
.sb-table-menu button:focus-visible {
  background: color-mix(in srgb, var(--ui-accent-color) 14%, transparent);
  outline: none;
}
.sb-table-menu button.is-current::after {
  content: " ✓";
}
.sb-table-menu button.is-danger {
  color: var(--ds-danger-fg, var(--root-color));
}
.sb-table-menu hr {
  margin: 4px 0;
  border: 0;
  border-top: 1px solid var(--ds-edge);
}
```

```space-lua
local doc = js.window.document
if not doc.getElementById("sb-table-edit") then
  local s = doc.createElement("script")
  s.id = "sb-table-edit"
  s.innerHTML = [[
(function () {
  const view = () => window.client && window.client.editorView;
  const notify = (msg) => { try { window.client.flashNotification(msg, "error"); } catch (e) { console.warn(msg); } };
  const len = s => Array.from(s).length;
  const ROW_RE = /^\s*\|/;
  const DELIM_RE = /^\s*\|?(\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\s*$/;
  const DELIM = { "": "---", left: ":---", center: ":---:", right: "---:" };

  // ---- markdown <-> model --------------------------------------------------
  function splitRow(line) {
    let s = line.trim();
    if (s.startsWith("|")) s = s.slice(1);
    if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
    const cells = [];
    let cur = "";
    for (let i = 0; i < s.length; i++) {
      if (s[i] === "\\" && s[i + 1] === "|") { cur += "\\|"; i++; continue; }
      if (s[i] === "|") { cells.push(cur.trim()); cur = ""; continue; }
      cur += s[i];
    }
    cells.push(cur.trim());
    return cells;
  }
  const alignOf = c => c.startsWith(":") && c.endsWith(":") ? "center"
    : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : "";

  // The table whose first line holds `pos`, read from the document itself
  function readTable(pos) {
    const v = view();
    if (!v || v.state.readOnly) return null;
    const doc = v.state.doc;
    if (pos < 0 || pos > doc.length) return null;
    const lines = [];
    for (let i = doc.lineAt(pos).number; i <= doc.lines; i++) {
      const l = doc.line(i);
      if (!ROW_RE.test(l.text)) break;
      lines.push(l);
    }
    if (lines.length < 2 || !DELIM_RE.test(lines[1].text)) return null;
    const align = splitRow(lines[1].text).map(alignOf);
    const rows = [lines[0], ...lines.slice(2)].map(l => {
      const c = splitRow(l.text);
      while (c.length < align.length) c.push("");
      // remembered so an untouched row is written back byte for byte
      c.src = l.text;
      c.was = c.join("|");
      return c;
    });
    const widths = lines.map(l => len(l.text));
    return {
      from: lines[0].from, to: lines[lines.length - 1].to,
      indent: lines[0].text.match(/^\s*/)[0], align, rows,
      delimSrc: lines[1].text, alignWas: align.join("|"),
      // aligned pipes in the source are kept aligned; compact tables stay compact
      padded: lines.length > 2 && widths.every(w => w === widths[0]) &&
        lines.some(l => /\S {2,}\|/.test(l.text)),
    };
  }

  function writeTable(t) {
    const cols = t.align.length;
    const w = [];
    t.rows.forEach(r => r.forEach((s, c) => { w[c] = Math.max(w[c] || 3, len(s)); }));
    const pad = (s, c) => {
      const gap = w[c] - len(s), a = t.align[c];
      const left = a === "right" ? gap : a === "center" ? Math.floor(gap / 2) : 0;
      return " ".repeat(left) + s + " ".repeat(gap - left);
    };
    const cell = (s, c) => t.padded ? " " + pad(s, c) + " " : s === "" ? " " : " " + s + " ";
    const delim = (a, c) => {
      if (!t.padded) return DELIM[a];
      const d = "-".repeat(w[c] - (a === "center" ? 2 : a ? 1 : 0));
      return " " + (a === "center" || a === "left" ? ":" : "") + d +
        (a === "center" || a === "right" ? ":" : "") + " ";
    };
    // a compact table only rewrites the rows that changed; a padded one re-pads every row
    const row = r => !t.padded && r.src != null && r.join("|") === r.was ? r.src
      : t.indent + "|" + r.map(cell).join("|") + "|";
    const delimLine = !t.padded && t.align.join("|") === t.alignWas ? t.delimSrc
      : t.indent + "|" + t.align.slice(0, cols).map(delim).join("|") + "|";
    return [row(t.rows[0]), delimLine, ...t.rows.slice(1).map(row)].join("\n");
  }

  const escapeCell = v => v.replace(/\r?\n/g, " ").trim()
    .replace(/(\\*)\|/g, (m, bs) => bs.length % 2 ? m : bs + "\\|");
  const unescapeCell = s => s.replace(/\\\|/g, "|");

  // Re-read the table at `pos`, let fn change the model, write it back as one edit.
  // fn returns false to abort; `rows` guards against the source having moved on.
  function mutate(loc, fn) {
    const v = view(), t = readTable(loc.pos);
    if (!t || t.rows.length !== loc.rows) { notify("Table changed underneath the edit; nothing saved"); return false; }
    if (fn(t) === false) return false;
    const text = writeTable(t);
    if (text !== v.state.sliceDoc(t.from, t.to)) {
      const tr = v.state.update({ changes: { from: t.from, to: t.to, insert: text }, userEvent: "input" });
      v.dispatch(tr);
      lastChanges = tr.changes;
    }
    return true;
  }

  let lastChanges = null;

  // ---- DOM <-> source position -----------------------------------------------
  function tablePos(table) {
    const v = view(), w = table && table.closest(".sb-table-widget");
    if (!v || !w || !v.contentDOM.contains(w)) return null;
    try { return v.posAtDOM(w, 0); } catch (e) { return null; }
  }
  function locate(cell) {
    const table = cell.closest("table"), tr = cell.parentElement;
    const pos = tablePos(table);
    if (pos == null) return null;
    const t = readTable(pos);
    if (!t || t.rows.length !== table.rows.length) return null;
    return { pos, rows: table.rows.length, cols: t.align.length,
      r: Array.prototype.indexOf.call(table.rows, tr),
      c: Array.prototype.indexOf.call(tr.cells, cell) };
  }
  function findTable(pos) {
    const v = view();
    if (!v) return null;
    for (const w of v.contentDOM.querySelectorAll(".sb-table-widget")) {
      let p;
      try { p = v.posAtDOM(w, 0); } catch (e) { continue; }
      const t = w.querySelector("table");
      if (p === pos && t) return t;
    }
    return null;
  }
  // CodeMirror swaps the widget synchronously but fills the table asynchronously
  function whenRendered(pos, rows, cb) {
    let tries = 0;
    const tick = () => {
      const t = findTable(pos);
      if (t && (rows == null || t.rows.length === rows)) return cb(t);
      if (++tries < 120) requestAnimationFrame(tick);
    };
    tick();
  }

  // ---- cell editor -----------------------------------------------------------
  let active = null;

  function openAt(pos, r, c) {
    whenRendered(pos, null, table => {
      const row = table.rows[r], cell = row && row.cells[c];
      if (cell) open(cell);
    });
  }

  function open(cell) {
    const loc = locate(cell);
    if (!loc) return;
    const src = (readTable(loc.pos).rows[loc.r] || [])[loc.c] || "";
    const ta = document.createElement("textarea");
    ta.className = "sb-cell-editor";
    ta.rows = 1;
    ta.spellcheck = true;
    ta.value = unescapeCell(src);
    const kept = document.createDocumentFragment();
    while (cell.firstChild) kept.appendChild(cell.firstChild);
    cell.classList.add("sb-cell-editing");
    cell.appendChild(ta);
    const fit = () => { ta.style.height = "auto"; ta.style.height = ta.scrollHeight + "px"; };
    active = { loc, src, ta, cell, kept };
    hideHandle();

    for (const ev of ["mousedown", "mouseup", "click", "dblclick", "keyup", "keypress",
      "beforeinput", "input", "paste", "copy", "cut", "compositionstart", "compositionend"])
      ta.addEventListener(ev, e => e.stopPropagation());
    ta.addEventListener("input", fit);
    ta.addEventListener("blur", () => { if (active && active.ta === ta) close(true, true); });
    ta.addEventListener("keydown", e => {
      e.stopPropagation();
      if (e.isComposing) return;
      if (e.key === "Escape") { e.preventDefault(); close(false); }
      else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); close(true); }
      else if (e.key === "Tab") { e.preventDefault(); step(e.shiftKey ? -1 : 1); }
    });
    ta.focus();
    ta.setSelectionRange(ta.value.length, ta.value.length);
    fit();
  }

  // Put the rendered cell back, then save if the text changed. Returns the row count after.
  function close(save, keepFocus) {
    const a = active;
    if (!a) return null;
    active = null;
    const value = escapeCell(a.ta.value);
    a.ta.remove();
    a.cell.classList.remove("sb-cell-editing");
    a.cell.appendChild(a.kept);
    if (save && value !== a.src) {
      mutate(a.loc, t => {
        if ((t.rows[a.loc.r][a.loc.c] || "") !== a.src) { notify("Cell changed underneath the edit; nothing saved"); return false; }
        while (t.rows[a.loc.r].length <= a.loc.c) t.rows[a.loc.r].push("");
        t.rows[a.loc.r][a.loc.c] = value;
      });
    }
    if (!keepFocus) { const v = view(); if (v) v.focus(); }
    return a.loc;
  }

  function step(dir) {
    const loc = close(true, true);
    if (!loc) return;
    let r = loc.r, c = loc.c + dir;
    if (c >= loc.cols) { c = 0; r++; }
    if (c < 0) { c = loc.cols - 1; r--; }
    if (r < 0) { const v = view(); if (v) v.focus(); return; }
    if (r >= loc.rows) {
      if (!mutate(loc, t => { t.rows.push(t.align.map(() => "")); })) return;
      whenRendered(loc.pos, loc.rows + 1, () => openAt(loc.pos, r, 0));
      return;
    }
    openAt(loc.pos, r, c);
  }

  // Plain click on a cell edits it; Alt/Ctrl/Meta/Shift-click and links keep SilverBullet's behaviour
  let swallowClick = false;
  window.addEventListener("mousedown", e => {
    if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
    const cell = e.target.closest && e.target.closest(".sb-table-widget td, .sb-table-widget th");
    if (!cell || cell.classList.contains("sb-cell-editing")) return;
    if (document.body.classList.contains("sb-col-resize-hover")) return;
    if (e.target.closest("a, button, input, textarea, select, label, .sb-task-state")) return;
    const loc = locate(cell);
    if (!loc) return;
    e.preventDefault();
    e.stopPropagation();
    swallowClick = true;
    setTimeout(() => { swallowClick = false; }, 600);
    closeMenu();
    if (active) {
      // saving the open cell may shift this table, so map its position through that edit
      lastChanges = null;
      close(true, true);
      openAt(lastChanges ? lastChanges.mapPos(loc.pos) : loc.pos, loc.r, loc.c);
    } else open(cell);
  }, true);
  window.addEventListener("click", e => {
    if (!swallowClick) return;
    swallowClick = false;
    if (e.target.closest && e.target.closest(".sb-table-widget")) { e.preventDefault(); e.stopPropagation(); }
  }, true);

  // ---- row / column menu -----------------------------------------------------
  const ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
  const GRIP = '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>';
  const mkHandle = (cls, title, svg) => {
    const b = document.createElement("button");
    b.className = "sb-table-handle" + cls;
    b.title = title;
    b.tabIndex = -1;
    b.innerHTML = svg;
    document.body.appendChild(b);
    return b;
  };
  const handle = mkHandle("", "Table row / column actions", ICON);
  const rowGrip = mkHandle(" is-grip", "Drag to move row", GRIP);
  const colGrip = mkHandle(" is-grip is-col", "Drag to move column", GRIP);
  const handles = [handle, rowGrip, colGrip];
  let hoverCell = null, menu = null, drag = null, hideTimer = 0;

  const place = (el, x, y) => {
    el.style.left = Math.max(0, x) + "px";
    el.style.top = Math.max(0, y) + "px";
    el.classList.add("is-visible");
  };
  function hideHandle() { handles.forEach(h => h.classList.remove("is-visible")); hoverCell = null; }
  function showHandle(cell) {
    const table = cell.closest("table"), tr = cell.parentElement;
    const r = cell.getBoundingClientRect(), tb = table.getBoundingClientRect();
    place(handle, r.right - 21, r.top + Math.max(2, (r.height - 18) / 2));
    // the header row is the header: it has no row grip
    if (tr.rowIndex > 0) {
      const rr = tr.getBoundingClientRect();
      place(rowGrip, tb.left - 21, rr.top + (rr.height - 18) / 2);
    } else rowGrip.classList.remove("is-visible");
    const head = table.rows[0].cells[cell.cellIndex];
    if (head) {
      const h = head.getBoundingClientRect();
      place(colGrip, h.left + (h.width - 18) / 2, h.top - 10);
    } else colGrip.classList.remove("is-visible");
    hoverCell = cell;
  }
  const cancelHide = () => { clearTimeout(hideTimer); hideTimer = 0; };

  // the grips sit outside the cell, so leaving the table hides them only after a pause
  document.addEventListener("mousemove", e => {
    if (menu || active || drag) return;
    if (handles.some(h => h.contains(e.target))) return cancelHide();
    const cell = e.target.closest && e.target.closest(".sb-table-widget td, .sb-table-widget th");
    if (!cell || document.body.classList.contains("sb-col-resizing")) {
      if (hoverCell && !hideTimer) hideTimer = setTimeout(() => { hideTimer = 0; hideHandle(); }, 250);
      return;
    }
    cancelHide();
    if (cell !== hoverCell && tablePos(cell.closest("table")) != null) showHandle(cell);
  }, { passive: true });
  window.addEventListener("scroll", () => { if (!drag) { hideHandle(); closeMenu(); } }, true);

  // ---- drag to reorder ---------------------------------------------------------
  function startDrag(e, axis) {
    if (e.button !== 0 || !hoverCell) return;
    e.preventDefault();
    e.stopPropagation();
    const cell = hoverCell, table = cell.closest("table"), loc = locate(cell);
    if (!loc) return;
    const from = axis === "row" ? loc.r : loc.c;
    const carried = axis === "row" ? Array.of(table.rows[from])
      : Array.from(table.rows, tr => tr.cells[from]).filter(Boolean);
    carried.forEach(el => el.classList.add("sb-table-dragged"));
    const line = document.createElement("div");
    line.className = "sb-table-drop";
    document.body.appendChild(line);
    document.body.classList.add("sb-table-dragging");
    drag = { to: from };

    // drag.to is a slot: "before row/column i", or the count for "at the end"
    const move = ev => {
      const tb = table.getBoundingClientRect();
      if (axis === "row") {
        let to = table.rows.length;
        for (let i = 1; i < table.rows.length; i++) {
          const b = table.rows[i].getBoundingClientRect();
          if (ev.clientY < b.top + b.height / 2) { to = i; break; }
        }
        drag.to = to;
        const y = to < table.rows.length ? table.rows[to].getBoundingClientRect().top : tb.bottom;
        Object.assign(line.style, { left: tb.left + "px", top: (y - 1) + "px", width: tb.width + "px", height: "2px" });
      } else {
        const hc = Array.from(table.rows[0].cells);
        let to = hc.length;
        for (let i = 0; i < hc.length; i++) {
          const b = hc[i].getBoundingClientRect();
          if (ev.clientX < b.left + b.width / 2) { to = i; break; }
        }
        drag.to = to;
        const x = to < hc.length ? hc[to].getBoundingClientRect().left : tb.right;
        Object.assign(line.style, { left: (x - 1) + "px", top: tb.top + "px", width: "2px", height: tb.height + "px" });
      }
    };
    const finish = commit => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("keydown", esc, true);
      carried.forEach(el => el.classList.remove("sb-table-dragged"));
      line.remove();
      document.body.classList.remove("sb-table-dragging");
      const to = drag.to;
      drag = null;
      hideHandle();
      const dest = to > from ? to - 1 : to;
      if (!commit || dest === from) return;
      mutate(loc, t => {
        const moveIn = a => { while (a.length <= from) a.push(""); const x = a.splice(from, 1)[0]; a.splice(dest, 0, x); };
        if (axis === "row") moveIn(t.rows);
        else { t.rows.forEach(moveIn); moveIn(t.align); }
      });
    };
    const up = () => finish(true);
    const esc = ev => { if (ev.key === "Escape") { ev.preventDefault(); ev.stopPropagation(); finish(false); } };
    move(e);
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("keydown", esc, true);
  }
  rowGrip.addEventListener("pointerdown", e => startDrag(e, "row"));
  colGrip.addEventListener("pointerdown", e => startDrag(e, "col"));
  for (const g of [rowGrip, colGrip]) g.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); });

  handle.addEventListener("mousedown", e => { e.preventDefault(); e.stopPropagation(); });
  handle.addEventListener("click", e => {
    e.preventDefault();
    e.stopPropagation();
    const cell = hoverCell, loc = cell && locate(cell);
    if (!loc) return hideHandle();
    const r = handle.getBoundingClientRect();
    openMenu(loc, r.right, r.bottom + 2);
  });

  function closeMenu() {
    if (!menu) return;
    menu.remove();
    menu = null;
    hideHandle();
  }
  document.addEventListener("mousedown", e => { if (menu && !menu.contains(e.target)) closeMenu(); }, true);
  document.addEventListener("keydown", e => { if (menu && e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeMenu(); } }, true);

  // (no destructuring swap: a double close-bracket would end the Lua long string)
  const swap = (a, i, j) => { while (a.length <= Math.max(i, j)) a.push(""); const x = a[i]; a[i] = a[j]; a[j] = x; };

  function openMenu(loc, x, y) {
    closeMenu();
    const { r, c, rows, cols } = loc;
    const head = r === 0;
    // then: [r, c] of the cell to edit afterwards, or nothing
    const act = (fn, nRows, then) => () => {
      closeMenu();
      if (!mutate(loc, fn)) return;
      if (then) whenRendered(loc.pos, nRows, () => openAt(loc.pos, then[0], then[1]));
    };
    const items = [
      ["Row"],
      !head && ["Insert row above", act(t => { t.rows.splice(r, 0, t.align.map(() => "")); }, rows + 1, [r, c])],
      ["Insert row below", act(t => { t.rows.splice(r + 1, 0, t.align.map(() => "")); }, rows + 1, [r + 1, c])],
      r > 1 && ["Move row up", act(t => { swap(t.rows, r, r - 1); })],
      !head && r < rows - 1 && ["Move row down", act(t => { swap(t.rows, r, r + 1); })],
      !head && ["Delete row", act(t => { t.rows.splice(r, 1); }), "is-danger"],
      ["Column"],
      ["Insert column left", act(t => { t.rows.forEach(row => { while (row.length < c) row.push(""); row.splice(c, 0, ""); }); t.align.splice(c, 0, ""); }, rows, [0, c])],
      ["Insert column right", act(t => { t.rows.forEach(row => { while (row.length <= c) row.push(""); row.splice(c + 1, 0, ""); }); t.align.splice(c + 1, 0, ""); }, rows, [0, c + 1])],
      c > 0 && ["Move column left", act(t => { t.rows.forEach(row => swap(row, c, c - 1)); swap(t.align, c, c - 1); })],
      c < cols - 1 && ["Move column right", act(t => { t.rows.forEach(row => swap(row, c, c + 1)); swap(t.align, c, c + 1); })],
      cols > 1 && ["Delete column", act(t => { t.rows.forEach(row => row.splice(c, 1)); t.align.splice(c, 1); }), "is-danger"],
      ["Align column"],
    ];
    const cur = (readTable(loc.pos) || { align: [] }).align[c] || "";
    const aligns = { Default: "", Left: "left", Center: "center", Right: "right" };
    for (const label in aligns) {
      const a = aligns[label];
      items.push([label, act(t => { t.align[c] = a; }), a === cur ? "is-current" : ""]);
    }

    menu = document.createElement("div");
    menu.className = "sb-table-menu";
    menu.setAttribute("role", "menu");
    let first = true;
    for (const it of items.filter(Boolean)) {
      if (it.length === 1) {
        if (!first) menu.appendChild(document.createElement("hr"));
        const h = document.createElement("div");
        h.className = "sb-table-menu-head";
        h.textContent = it[0];
        menu.appendChild(h);
      } else {
        const b = document.createElement("button");
        b.setAttribute("role", "menuitem");
        b.textContent = it[0];
        if (it[2]) b.className = it[2];
        b.addEventListener("mousedown", e => e.preventDefault());
        b.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); it[1](); });
        menu.appendChild(b);
      }
      first = false;
    }
    document.body.appendChild(menu);
    const m = menu.getBoundingClientRect();
    menu.style.left = Math.max(4, Math.min(x - m.width, innerWidth - m.width - 4)) + "px";
    menu.style.top = (y + m.height > innerHeight - 4 ? Math.max(4, y - m.height - 24) : y) + "px";
    handle.classList.add("is-visible");
  }
})();
]]
  doc.head.appendChild(s)
end
```

nice one :+1:

Really usefull and handy