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}