Download
305 lines 9 KiBJavaScriptModified 2026-08-01 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
// In-place layout editing on the real bar. While active, widget
// interactions are suppressed: dragging a widget reorders it live, a ✕
// badge on each widget removes it, and a pop-down toolbar under the bar
// offers "+ Add widget" (inline picker) and "Done". Every change saves
// through save_config, so the bar re-renders itself via the usual
// divebar://config round trip — the host calls refresh() after each
// re-render to re-decorate the (possibly new) widget elements.

import WIDGET_NAMES from "../widgets/index.js";
import { createPopup } from "./popup.js";

const CSS = `
  .layout-editing #bar > * {
    position: relative;
    cursor: grab;
    outline: 1px dashed rgba(255, 255, 255, 0.28);
    outline-offset: 2px;
    border-radius: var(--chip-radius);
  }
  .layout-editing #bar > .layout-dragging {
    opacity: 0.55;
    outline: 2px solid var(--accent);
  }
  /* Spacers are invisible and can collapse to zero width; keep them
     grabbable while editing. */
  .layout-editing .widget-spacer { min-width: var(--chip-height); }
  .layout-x {
    position: absolute;
    top: -3px;
    right: -3px;
    z-index: 5;
    width: 16px;
    height: 16px;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: var(--critical);
    color: #10121a;
    font-size: 10px;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
  }
  .layout-toolbar {
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 10px 12px;
    font-family: var(--font);
    font-size: calc(var(--bar-font) * 0.8);
    color: var(--bar-fg);
  }
  .layout-hint { color: var(--bar-fg-dim); text-align: center; }
  .layout-buttons { display: flex; gap: 8px; justify-content: center; }
  .layout-toolbar button {
    font: inherit;
    color: var(--bar-fg);
    background: var(--chip-bg);
    border: 1px solid rgba(255, 255, 255, 0.12);
    border-radius: 8px;
    padding: 5px 12px;
    cursor: pointer;
  }
  .layout-toolbar button:hover { background: var(--chip-bg-hover); }
  .layout-toolbar button.primary {
    background: var(--accent);
    border-color: transparent;
    color: #10121a;
    font-weight: 600;
  }
  .layout-add-menu {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 4px;
    /* The window is only POPUP_SPACE (320px) tall below the bar. */
    max-height: 200px;
    overflow-y: auto;
  }
  .layout-add-menu:empty { display: none; }
  .layout-add-menu button { text-align: left; }
`;

let stylesInjected = false;

// The sibling the dragged element should sit in front of at pointer
// position x, or null to move it to the end of the bar.
export function reorderTarget(barEl, draggedEl, x) {
  for (const child of barEl.children) {
    if (child === draggedEl) continue;
    const r = child.getBoundingClientRect();
    if (x < r.left + r.width / 2) return child;
  }
  return null;
}

// getConfig() returns the live config object; entryFor(el) maps a bar
// child element back to its config.widgets entry (both provided by
// main.js, which owns the mounted-widget bookkeeping).
export function createLayoutEditor({ barEl, invoke, getConfig, entryFor }) {
  let active = false;
  let toolbar = null;
  let drag = null; // { el, startX, moved }

  function save(widgets) {
    const config = { ...getConfig(), widgets };
    invoke("save_config", { config }).catch((err) =>
      console.error("divebar: save_config failed", err),
    );
  }

  function widgetsInDomOrder() {
    return [...barEl.children].map(entryFor).filter(Boolean);
  }

  function removeWidget(el) {
    save([...barEl.children].filter((c) => c !== el).map(entryFor).filter(Boolean));
  }

  // ✕ badge on every widget that doesn't have one yet (re-renders keep
  // existing widget elements, so this is idempotent per element).
  function decorate() {
    for (const widgetEl of barEl.children) {
      if (widgetEl.querySelector(":scope > .layout-x")) continue;
      const x = document.createElement("button");
      x.className = "layout-x";
      x.textContent = "✕";
      x.title = "Remove";
      x.addEventListener("click", () => removeWidget(widgetEl));
      widgetEl.append(x);
    }
  }

  // --- Drag to reorder -------------------------------------------------

  function onPointerDown(e) {
    if (!barEl.contains(e.target) || e.target.closest(".layout-x")) return;
    // Swallow the event so the widget under the pointer never reacts.
    e.preventDefault();
    e.stopPropagation();
    if (e.button !== 0) return;
    const el = [...barEl.children].find((c) => c.contains(e.target));
    if (!el) return;
    drag = { el, startX: e.clientX, moved: false };
    // Keep receiving moves even when the pointer leaves the window.
    try {
      barEl.setPointerCapture?.(e.pointerId);
    } catch {
      /* no pointer capture in this environment */
    }
  }

  function onPointerMove(e) {
    if (!drag) return;
    if (!drag.moved) {
      if (Math.abs(e.clientX - drag.startX) < 5) return;
      drag.moved = true;
      drag.el.classList.add("layout-dragging");
    }
    // Reorder live: the dragged widget snaps into the slot under the
    // pointer, so the bar always previews the drop result.
    const before = reorderTarget(barEl, drag.el, e.clientX);
    if (before !== drag.el.nextElementSibling) barEl.insertBefore(drag.el, before);
  }

  function onPointerUp() {
    if (!drag) return;
    const { el, moved } = drag;
    drag = null;
    el.classList.remove("layout-dragging");
    if (moved) save(widgetsInDomOrder());
  }

  // --- Suppress normal widget interactions -----------------------------

  function onClick(e) {
    if (!barEl.contains(e.target) || e.target.closest(".layout-x")) return;
    e.preventDefault();
    e.stopPropagation();
  }

  function onWheel(e) {
    if (!barEl.contains(e.target)) return;
    e.preventDefault();
    e.stopPropagation();
  }

  function onContextMenu(e) {
    e.preventDefault();
    e.stopPropagation();
  }

  function onKeyDown(e) {
    if (e.key === "Escape") stop();
  }

  // --- Toolbar ---------------------------------------------------------

  function button(label, className = "") {
    const b = document.createElement("button");
    if (className) b.className = className;
    b.textContent = label;
    return b;
  }

  function buildToolbar() {
    return createPopup({
      anchor: barEl,
      className: "layout-toolbar",
      holdOpen: () => true, // never auto-close on pointer-leave
      onClose: teardown,
      render(el) {
        const hint = document.createElement("div");
        hint.className = "layout-hint";
        hint.textContent = "Drag widgets to rearrange · ✕ removes";

        const buttons = document.createElement("div");
        buttons.className = "layout-buttons";
        const add = button("+ Add widget");
        const done = button("Done", "primary");

        const menu = document.createElement("div");
        menu.className = "layout-add-menu";
        add.addEventListener("click", () => {
          if (menu.childElementCount > 0) {
            menu.replaceChildren();
            return;
          }
          for (const name of [...WIDGET_NAMES].sort()) {
            const item = button(name);
            item.addEventListener("click", () => {
              menu.replaceChildren();
              save([...widgetsInDomOrder(), { widget: name, options: {} }]);
            });
            menu.append(item);
          }
        });
        done.addEventListener("click", () => stop());

        buttons.append(add, done);
        el.append(hint, buttons, menu);
      },
    });
  }

  // --- Lifecycle -------------------------------------------------------

  const interceptors = [
    ["pointerdown", onPointerDown],
    ["pointermove", onPointerMove],
    ["pointerup", onPointerUp],
    ["pointercancel", onPointerUp],
    ["click", onClick],
    ["wheel", onWheel],
    ["contextmenu", onContextMenu],
    ["keydown", onKeyDown],
  ];

  function start() {
    if (active) return;
    if (!stylesInjected) {
      stylesInjected = true;
      const style = document.createElement("style");
      style.dataset.lib = "layout-edit";
      style.textContent = CSS;
      document.head.appendChild(style);
    }
    active = true;
    document.documentElement.classList.add("layout-editing");
    for (const [name, fn] of interceptors) {
      document.addEventListener(name, fn, { capture: true, passive: false });
    }
    decorate();
    toolbar = buildToolbar();
    toolbar.open();
  }

  // Runs when the toolbar closes by any path (Done, Escape, click-away).
  function teardown() {
    if (!active) return;
    active = false;
    toolbar = null;
    drag = null;
    document.documentElement.classList.remove("layout-editing");
    for (const [name, fn] of interceptors) {
      document.removeEventListener(name, fn, { capture: true });
    }
    for (const x of barEl.querySelectorAll(".layout-x")) x.remove();
  }

  function stop() {
    toolbar?.close(); // close → onClose → teardown
  }

  return {
    start,
    stop,
    isActive: () => active,
    // Re-decorate after the bar re-renders (new widgets need badges).
    refresh() {
      if (active) decorate();
    },
  };
}