4.4 KiB · text History 487bb12
// Bundled to f/js/app.js by esbuild (see the Justfile). Importing htmx here
// registers it globally and processes hx-* attributes on load, which is what
// powers hx-boost navigation and the live card preview.
import "htmx.org";

// Small toast helper available as window.toast(msg, {kind}) for ad-hoc use.
window.toast = (msg, opts = {}) => {
  let host = document.getElementById("toast-host");
  if (!host) {
    host = document.createElement("div");
    host.id = "toast-host";
    host.className = "toast toast-end z-50";
    document.body.appendChild(host);
  }
  const alert = document.createElement("div");
  alert.className = `alert alert-${opts.kind || "info"} shadow-lg`;
  const span = document.createElement("span");
  span.textContent = msg;
  alert.appendChild(span);
  host.appendChild(alert);
  const ttl = opts.ttl ?? 4000;
  if (ttl > 0) setTimeout(() => alert.remove(), ttl);
  return alert;
};

// Code textareas take Tab as a character instead of moving focus (Esc then
// Tab still leaves). execCommand keeps native undo and fires `input`, which
// is what re-renders the editor preview.
let escaped = false;
document.addEventListener("keydown", (e) => {
  const el = e.target;
  if (!(el instanceof HTMLTextAreaElement) || !el.hasAttribute("data-code-input")) return;
  if (e.key === "Escape") { escaped = true; return; }
  if (e.key !== "Tab" || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) { escaped = false; return; }
  if (escaped) { escaped = false; return; }
  e.preventDefault();
  if (!document.execCommand("insertText", false, "\t")) {
    el.setRangeText("\t", el.selectionStart, el.selectionEnd, "end");
    el.dispatchEvent(new Event("input", { bubbles: true }));
  }
});

// In-window editing. The preview SVG says where its code grid sits
// (data-code-x/-y, line height, font size, all in SVG units); a transparent,
// caret-only textarea is laid exactly over that grid at the SVG's current
// scale. Typing there mirrors into the real (named) code box, whose `input`
// re-renders the preview underneath; highlighted text shows through.
function placeInWindowEditor(preview) {
  const svg = preview.querySelector("#preview-inner svg");
  const ta = preview.querySelector("textarea[data-in-window]");
  if (!svg || !ta || !svg.dataset.codeX) return;
  const vb = svg.viewBox.baseVal;
  const r = svg.getBoundingClientRect();
  const p = preview.getBoundingClientRect();
  if (!vb || !vb.width || !r.width) return;
  const k = r.width / vb.width;
  const d = svg.dataset;
  const x = +d.codeX;
  const y = +d.codeY;
  // Offsets inside #preview's padding box, which positions the textarea.
  const ox = r.left - p.left - preview.clientLeft + preview.scrollLeft;
  const oy = r.top - p.top - preview.clientTop + preview.scrollTop;
  Object.assign(ta.style, {
    left: `${ox + x * k}px`,
    top: `${oy + y * k}px`,
    // Reach to the SVG's far edges, not just the code area, so text typed
    // past the current width or height stays in view until the window grows.
    width: `${(vb.width - x) * k}px`,
    height: `${(vb.height - y) * k}px`,
    fontSize: `${+d.fontSize * k}px`,
    lineHeight: `${+d.lineHeight * k}px`,
    caretColor: d.codeFg,
  });
  ta.scrollLeft = 0;
  ta.scrollTop = 0;
}

function initInWindowEditing(root) {
  const preview = root.querySelector ? root.querySelector("#preview") || (root.id === "preview" ? root : null) : null;
  if (!preview || preview.dataset.inWindowReady) return;
  const ta = preview.querySelector("textarea[data-in-window]");
  const code = document.querySelector("#card-form textarea[name=code]");
  if (!ta || !code) return;
  preview.dataset.inWindowReady = "1";

  ta.value = code.value;
  ta.addEventListener("input", () => { code.value = ta.value; });
  code.addEventListener("input", () => { if (ta.value !== code.value) ta.value = code.value; });
  // Keep the grid pinned: never let the textarea scroll its text away from
  // the rendered text under it.
  ta.addEventListener("scroll", () => { ta.scrollLeft = 0; ta.scrollTop = 0; });

  const place = () => placeInWindowEditor(preview);
  preview.addEventListener("htmx:afterSwap", place);
  preview.addEventListener("scroll", place);
  window.addEventListener("resize", place);
  if (window.ResizeObserver) new ResizeObserver(place).observe(preview);
  document.fonts && document.fonts.ready.then(place);
  place();
}

document.addEventListener("htmx:load", (e) => initInWindowEditing(e.detail.elt));