v0.154.0
  1// window.plainToolbar is exposed for the JS console (hide/show, etc.).
  2window.plainToolbar = window.plainToolbar || {
  3  // State mirrors the data-* attributes on #plaintoolbar.
  4  expanded: false,
  5  collapsed: true,
  6  position: "left",
  7  itemsOpen: false,
  8
  9  el: () => document.getElementById("plaintoolbar"),
 10  bar: () => document.getElementById("plaintoolbar-bar"),
 11
 12  hide: function () {
 13    this.el()?.setAttribute("data-hidden", "");
 14    this.syncBodyState();
 15  },
 16  show: function () {
 17    localStorage.removeItem("plaintoolbar.hidden_until");
 18    this.el()?.removeAttribute("data-hidden");
 19    this.syncBodyState();
 20  },
 21  shouldHide: () => {
 22    const until = localStorage.getItem("plaintoolbar.hidden_until");
 23    if (until && Date.now() < Number(until)) return true;
 24    if (until) localStorage.removeItem("plaintoolbar.hidden_until");
 25    return false;
 26  },
 27  hideUntil: function (until) {
 28    localStorage.setItem("plaintoolbar.hidden_until", until);
 29    this.hide();
 30  },
 31
 32  // --- Details panel --------------------------------------------------------
 33  setExpanded: function (expanded, persist = true) {
 34    this.expanded = expanded;
 35    this.el().dataset.expanded = expanded ? "true" : "false";
 36    if (persist) {
 37      localStorage.setItem("plaintoolbar.expanded", expanded ? "1" : "0");
 38    }
 39  },
 40  expand: function () {
 41    this.setExpanded(true);
 42  },
 43  collapse: function () {
 44    this.setExpanded(false);
 45  },
 46  toggleExpand: function () {
 47    this.setExpanded(!this.expanded);
 48  },
 49
 50  // --- Bar: collapsed pill vs. full-width bar -------------------------------
 51  setCollapsed: function (collapsed, persist = true) {
 52    this.collapsed = collapsed;
 53    this.el().dataset.collapsed = collapsed ? "true" : "false";
 54    // Docking/undocking is instant — only a snap or cycle animates the pill.
 55    this.bar()?.removeAttribute("data-animate");
 56    if (persist) {
 57      localStorage.setItem("plaintoolbar.bar_collapsed", collapsed ? "1" : "0");
 58    }
 59    this.syncBodyState();
 60  },
 61
 62  // --- Pill position --------------------------------------------------------
 63  // The three positions are pure CSS (translateX); flipping data-position with
 64  // data-animate set lets the browser do the slide.
 65  setPosition: function (position, persist = true) {
 66    this.position = position;
 67    this.el().dataset.position = position;
 68    if (persist) {
 69      localStorage.setItem("plaintoolbar.position", position);
 70    }
 71  },
 72  cyclePosition: function () {
 73    const order = ["left", "center", "right"];
 74    this.bar()?.setAttribute("data-animate", "");
 75    this.setPosition(order[(order.indexOf(this.position) + 1) % order.length]);
 76  },
 77
 78  // --- Mobile items dropdown ------------------------------------------------
 79  setItemsOpen: function (open) {
 80    this.itemsOpen = open;
 81    this.el().dataset.itemsOpen = open ? "true" : "false";
 82  },
 83
 84  // --- Tabs -----------------------------------------------------------------
 85  // Switch which tab's content is visible. Pure: no effect on collapse/expand.
 86  selectTab: function (tabName, persist = true) {
 87    const toolbar = this.el();
 88    const tab = toolbar.querySelector(`div[data-toolbar-tab="${CSS.escape(tabName)}"]`);
 89    if (!tab) {
 90      console.warn(`Toolbar tab ${tabName} does not exist`);
 91      return;
 92    }
 93    for (const child of tab.parentNode.children) {
 94      child.classList.toggle("hidden", child !== tab);
 95    }
 96    for (const btn of toolbar.querySelectorAll("button[data-toolbar-tab]")) {
 97      btn.toggleAttribute("data-active", btn.dataset.toolbarTab === tabName);
 98    }
 99    if (persist) {
100      localStorage.setItem("plaintoolbar.tab", tabName);
101    }
102  },
103  // Reveal a tab: dock the bar (visually, not persisted) and open the panel.
104  showTab: function (tabName) {
105    this.setCollapsed(false, false);
106    this.expand();
107    this.selectTab(tabName);
108  },
109
110  setHeight: (height) => {
111    // Inline style: the panel height is a continuous drag value with no class
112    // equivalent. CSP permits CSSOM property setters — only style="" attributes
113    // and <style> tags are restricted.
114    const content = document.querySelector("#plaintoolbar-details > div.overflow-auto");
115    if (content) content.style.height = height;
116  },
117  resetHeight: function () {
118    this.setHeight("");
119    localStorage.removeItem("plaintoolbar.height");
120  },
121
122  // Reflect whether the full-width bar is showing onto <body>, so host layouts
123  // (e.g. the admin) reserve space only when it's needed.
124  syncBodyState: function () {
125    const t = this.el();
126    const visible = t && !t.hasAttribute("data-hidden");
127    const fullBar = !!visible && !this.collapsed && window.matchMedia("(min-width: 640px)").matches;
128    document.body.toggleAttribute("data-toolbar-fullbar", fullBar);
129  },
130};
131
132// Hide immediately if the user dismissed the toolbar earlier.
133if (window.plainToolbar.shouldHide()) {
134  window.plainToolbar.hide();
135}
136
137function initToolbar() {
138  const plainToolbar = window.plainToolbar;
139  const toolbar = plainToolbar.el();
140  if (!toolbar || plainToolbar._initialized) return;
141  plainToolbar._initialized = true;
142
143  const isDesktop = () => window.matchMedia("(min-width: 640px)").matches;
144
145  // --- Restore persisted state ---------------------------------------------
146  // Restoring is silent — only real user input writes to localStorage.
147  plainToolbar.setPosition(localStorage.getItem("plaintoolbar.position") || "left", false);
148  // Default (no saved value) is the collapsed pill.
149  plainToolbar.setCollapsed(localStorage.getItem("plaintoolbar.bar_collapsed") !== "0", false);
150  plainToolbar.setExpanded(localStorage.getItem("plaintoolbar.expanded") === "1", false);
151  const savedTab = localStorage.getItem("plaintoolbar.tab");
152  if (savedTab) plainToolbar.selectTab(savedTab);
153  const savedHeight = localStorage.getItem("plaintoolbar.height");
154  if (savedHeight) plainToolbar.setHeight(savedHeight);
155
156  // An exception forces the toolbar fully open — temporary, not persisted.
157  if (toolbar.querySelector('[data-toolbar-tab="Exception"]')) {
158    plainToolbar.show();
159    plainToolbar.setCollapsed(false, false);
160    plainToolbar.setExpanded(true, false);
161    plainToolbar.selectTab("Exception", false);
162  }
163  plainToolbar.syncBodyState();
164
165  // --- Click handling (delegated) ------------------------------------------
166  toolbar.addEventListener("click", (e) => {
167    const tabBtn = e.target.closest("button[data-toolbar-tab]");
168    if (tabBtn) {
169      plainToolbar.setItemsOpen(false);
170      // Clicking the active tab while the panel is open closes the panel.
171      if (plainToolbar.expanded && tabBtn.hasAttribute("data-active")) {
172        plainToolbar.collapse();
173      } else {
174        plainToolbar.showTab(tabBtn.dataset.toolbarTab);
175      }
176    } else if (e.target.closest("[data-plaintoolbar-hide]")) {
177      plainToolbar.hide();
178    } else if (e.target.closest("[data-plaintoolbar-hideuntil]")) {
179      console.log("Hiding toolbar for 1 hour");
180      plainToolbar.hideUntil(Date.now() + 3600000);
181    } else if (e.target.closest("[data-plaintoolbar-expand]")) {
182      plainToolbar.toggleExpand();
183    } else if (e.target.closest("[data-plaintoolbar-collapse]")) {
184      plainToolbar.setCollapsed(true);
185    } else if (e.target.closest("#plaintoolbar-version")) {
186      // Desktop pill: dock the bare bar. Mobile: toggle the items dropdown.
187      if (isDesktop()) {
188        plainToolbar.setCollapsed(false);
189        plainToolbar.collapse();
190      } else {
191        plainToolbar.setItemsOpen(!plainToolbar.itemsOpen);
192      }
193    }
194  });
195
196  // Outside-click dismiss for the mobile items dropdown.
197  document.addEventListener("click", (e) => {
198    if (plainToolbar.itemsOpen && !toolbar.contains(e.target)) {
199      plainToolbar.setItemsOpen(false);
200    }
201  });
202
203  // --- Drag the collapsed pill to reposition it ----------------------------
204  // During a drag the pill follows the pointer via an inline transform (no
205  // transition). On release, data-animate is set and data-position flipped so
206  // the CSS transition slides it to the snapped third.
207  const bar = plainToolbar.bar();
208  if (bar) {
209    const THRESHOLD = 4;
210    let drag = null; // { startX, fromX, width, moved }
211    let suppressClick = false;
212
213    const pointer = (e) => (e.touches && e.touches[0]) || e;
214    const isPill = () => !(plainToolbar.collapsed === false && isDesktop());
215
216    const onDown = (e) => {
217      suppressClick = false;
218      if (!isPill()) return;
219      const rect = bar.getBoundingClientRect();
220      drag = {
221        startX: pointer(e).clientX,
222        fromX: rect.left,
223        width: rect.width,
224        moved: false,
225      };
226    };
227
228    const endDrag = () => {
229      if (!drag) return;
230      const moved = drag.moved;
231      drag = null;
232      if (!moved) return;
233      // Snap to the third of the viewport the pill's center landed in.
234      const rect = bar.getBoundingClientRect();
235      const center = rect.left + rect.width / 2;
236      const w = window.innerWidth;
237      const position = center >= (2 * w) / 3 ? "right" : center >= w / 3 ? "center" : "left";
238      bar.setAttribute("data-animate", ""); // arm the CSS slide
239      plainToolbar.setPosition(position); // data-position drives the target
240      bar.style.transform = ""; // drop the inline override -> CSS animates
241      document.body.classList.remove("select-none");
242      suppressClick = true;
243      // Clear on the next task in case no click follows the drag (released
244      // off-bar, touch, cancelled gesture) — a stale flag would otherwise
245      // swallow a later keyboard activation.
246      setTimeout(() => {
247        suppressClick = false;
248      }, 0);
249    };
250
251    const onMove = (e) => {
252      if (!drag) return;
253      // A mouse drag whose button was released off-window never delivers a
254      // mouseup — end the drag if no button is held anymore.
255      if (e.type === "mousemove" && e.buttons === 0) {
256        endDrag();
257        return;
258      }
259      const dx = pointer(e).clientX - drag.startX;
260      if (!drag.moved && Math.abs(dx) < THRESHOLD) return;
261      if (!drag.moved) {
262        drag.moved = true;
263        bar.removeAttribute("data-animate"); // 1:1 tracking — no transition
264        document.body.classList.add("select-none");
265      }
266      const x = Math.max(0, Math.min(window.innerWidth - drag.width, drag.fromX + dx));
267      bar.style.transform = `translateX(${x}px)`;
268      e.preventDefault();
269    };
270
271    bar.addEventListener("mousedown", onDown);
272    document.addEventListener("mousemove", onMove);
273    document.addEventListener("mouseup", endDrag);
274    bar.addEventListener("touchstart", onDown, { passive: true });
275    document.addEventListener("touchmove", onMove, { passive: false });
276    document.addEventListener("touchend", endDrag);
277    document.addEventListener("touchcancel", endDrag);
278
279    // Swallow the click that fires right after a drag so the release doesn't
280    // also activate a button inside the pill.
281    bar.addEventListener(
282      "click",
283      (e) => {
284        if (suppressClick) {
285          suppressClick = false;
286          e.stopPropagation();
287          e.preventDefault();
288        }
289      },
290      true,
291    );
292  }
293
294  // --- Keep state in sync across viewport changes --------------------------
295  window.addEventListener("resize", () => {
296    // Desktop has no items dropdown; close it so it can't reappear if the
297    // viewport later crosses back below the breakpoint.
298    if (isDesktop()) plainToolbar.setItemsOpen(false);
299    plainToolbar.syncBodyState();
300  });
301
302  // --- Manual resize of the expanded panel via its drag handle -------------
303  const details = document.getElementById("plaintoolbar-details");
304  const handle = details && details.querySelector("[data-resizer]");
305  const content = handle && handle.nextElementSibling;
306  if (handle && content) {
307    let startY = 0;
308    let startHeight = 0;
309    let currentHeight = null;
310    const onResizeMove = (e) => {
311      const newHeight = Math.max(
312        50,
313        Math.min(window.innerHeight - 100, startHeight - (e.clientY - startY)),
314      );
315      currentHeight = `${newHeight}px`;
316      plainToolbar.setHeight(currentHeight);
317    };
318    const onResizeEnd = () => {
319      handle.classList.replace("cursor-grabbing", "cursor-grab");
320      document.body.classList.remove("select-none");
321      document.removeEventListener("mousemove", onResizeMove);
322      document.removeEventListener("mouseup", onResizeEnd);
323      if (currentHeight) {
324        localStorage.setItem("plaintoolbar.height", currentHeight);
325      }
326    };
327    handle.addEventListener("mousedown", (e) => {
328      startY = e.clientY;
329      startHeight = content.offsetHeight;
330      handle.classList.replace("cursor-grab", "cursor-grabbing");
331      document.body.classList.add("select-none");
332      document.addEventListener("mousemove", onResizeMove);
333      document.addEventListener("mouseup", onResizeEnd);
334      e.preventDefault();
335    });
336  }
337}
338
339// The script is deferred, so the DOM is already parsed when it runs. Initialize
340// immediately (rather than on `load`) so restored state is applied before first
341// paint — no pill-to-bar flash, no admin layout shift.
342if (document.readyState === "loading") {
343  document.addEventListener("DOMContentLoaded", initToolbar);
344} else {
345  initToolbar();
346}