PageSourceSearch

https://psmud.net/js/sitenav.js?v=1790651596

js psmud.net collected 2026-10-06 20:01:46 UTC 17,729 bytes, 440 lines download raw bytes

1// sitenav.js — 全站導覽的行為(NAV_PLAN.md 階段 B/C)。
2// 標記是伺服器端 SSR 的(requestHandlers 的 _siteNav(),資料來自 nav-data.js),
3// 這支只負責互動:分組下拉、手機抽屜、目前所在頁、索引 bar 的提示文字。
4//
5// **漸進增強**:沒有這支(或 JS 掛掉)導覽照樣可用 ——
6// 連結全都是真的 <a>、搜尋是真的 <form>,分組面板由 CSS 的 :hover/:focus-within 展開
7// (library.css 的 `html:not(.js-nav)` 那條規則)。所以第一件事就是掛上 .js-nav,
8// 告訴 CSS「互動交給我了,hover 展開可以關掉」。
9//
10// 純 vanilla、不依賴 jQuery:這支要掛在每一頁,包含不載 jQuery 的精簡 SSR 頁。
11(function() {
12	"use strict";
13	var doc = document;
14	var nav = doc.querySelector('.site-nav');
15	if (!nav) return; // 沒有導覽的頁面(worldmap/圍獵等全螢幕應用)直接跳出
16
17	doc.documentElement.className += ' js-nav';
18
19	var groups = [].slice.call(nav.querySelectorAll('.sn-grp'));
20	var burger = nav.querySelector('.sn-burger');
21	var drawer = doc.getElementById('sn-drawer');
22	var hoverable = !!(window.matchMedia && window.matchMedia('(hover: hover)').matches);
23
24	/* ---------- 分組下拉 ---------- */
25	var openGrp = null; // 目前展開的分組(同時間只會有一個),由 setGroup 維護
26	// aria-expanded 與 hidden 屬性一起改:只改 class 的話,螢幕閱讀器讀到的狀態
27	// 會跟畫面不一致(這是 NAV_PLAN §7 的規格)。
28	function setGroup(g, open) {
29		var btn = g.querySelector('.sn-gbtn'),
30			panel = g.querySelector('.sn-panel');
31		btn.setAttribute('aria-expanded', String(open));
32		panel.hidden = !open;
33		// 「現在開著的是哪一個」由這裡單點維護:點擊、hover、Esc、點空白處全都走 setGroup,
34		// 所以 hover 的意圖判斷(見下方)不必自己追狀態,也就不會跟其他路徑對不上。
35		if (open) openGrp = g;
36		else if (openGrp === g) openGrp = null;
37	}
38
39	function closeGroups(except) {
40		groups.forEach(function(g) {
41			if (g !== except) setGroup(g, false);
42		});
43	}
44
45	/* ⚠ **一定要走這支,不要在外面寫第二份綁定**(AUTH_PLAN §2-5 標的坑):
46	   原本這裡是 `groups.forEach(...)` 直接綁 —— **載入時一次抓齊**。
47	   使用者選單是登入之後才由 paintUser() 插進來的,那時這段早就跑完了,
48	   新按鈕會完全沒有反應,**而且不會有任何錯誤訊息**。
49	   抽成具名函式之後,動態插入的分組只要呼叫 wireGroup() 就跟原生的一模一樣。 */
50	function wireGroup(g) {
51		if (groups.indexOf(g) < 0) groups.push(g); // closeGroups 要看得到它,否則兩個面板會同時開著
52		var btn = g.querySelector('.sn-gbtn');
53		btn.addEventListener('click', function(e) {
54			e.stopPropagation();
55			var open = btn.getAttribute('aria-expanded') !== 'true';
56			closeGroups(g);
57			setGroup(g, open);
58		});
59		if (hoverable) wireHover(g);
60	}
61
62	// 點空白處收起。用捕獲階段以外的一般冒泡即可(上面已 stopPropagation)
63	doc.addEventListener('click', function(e) {
64		if (!nav.contains(e.target)) closeGroups(null);
65	});
66
67	/* ---------- 滑過就展開(2026-08-03;2026-08-29 改寫收合判斷) ---------- */
68	// **走 JS 而不是 CSS `:hover`**:CSS 開的話畫面展開了、`aria-expanded` 還停在 false,
69	// 螢幕閱讀器讀到的狀態會與眼睛看到的不一致(NAV_PLAN §7 的規格)。這裡沿用同一組
70	// setGroup/closeGroups,狀態永遠同步。點擊照舊可用(鍵盤與觸控靠它)。
71	//
72	// 只在**真的有滑鼠**的裝置啟用:觸控裝置的 hover 是點一下模擬出來的,會和 click 打架。
73	//
74	/* ⚠⚠ 2026-08-29 使用者回報:「大解析度(3000×2000)時,選單展開後滑鼠還沒移進去就收起來」,
75	   1920 以下也會、只是不明顯。**根因不是延遲太短,是收合的判準錯了** ——
76	   舊版拿 `mouseleave` 當「使用者要離開了」,但那個事件講的只是「指標跨過了元素邊界」:
77
78	     ① 按鈕與面板之間有 8px 空隙(.sn-panel 的 top: calc(100% + 8px)),
79	        指標經過空隙就算離開 .sn-grp(已另外用 .sn-panel::before 把空隙補進感應範圍);
80	     ② 真正被解析度放大的是**過衝**:高 DPI 螢幕同樣的手勢會走過更多 CSS px,
81	        一次 mousemove 可以直接從按鈕跳到面板下方好幾百 px。人要修正回來得 200~400ms,
82	        舊版 180ms 早就把面板關掉了 —— 於是「還沒碰到就消失」。
83
84	   所以改成**看指標座標**:面板只有在指標確實離開「按鈕+面板的聯集範圍(含中間空隙)」
85	   一段距離之後才收。邊界事件只留著當保險絲(指標可能移出視窗、之後不再有 mousemove),
86	   真正決定收不收的是計時器到點時的那次座標判斷。 */
87	var HOVER_CLOSE_MS = 400; // 過衝之後人修正回來的時間(200~400ms)要放得進去
88	var HOVER_SLACK = 24; // 貼著邊緣掠過不算離開:感應範圍往外放這麼多 px
89	var HOVER_FAR = 260; // 但明確走遠了就別拖:直接收,不等計時器
90	var closeTimer = null;
91	var lastX = -1e5,
92		lastY = -1e5;
93	var cancelClose = function() {
94		if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
95	};
96
97	// 指標到「感應範圍」的距離(在範圍內=0)。範圍=按鈕矩形 ∪ 面板矩形的外接矩形,
98	// 兩者上下相鄰,所以外接矩形自然把中間的空隙也包進來。
99	function hoverDist(g, x, y) {
100		var r = g.querySelector('.sn-gbtn').getBoundingClientRect();
101		var panel = g.querySelector('.sn-panel');
102		var left = r.left,
103			right = r.right,
104			top = r.top,
105			bottom = r.bottom;
106		if (!panel.hidden) {
107			var p = panel.getBoundingClientRect();
108			left = Math.min(left, p.left);
109			right = Math.max(right, p.right);
110			top = Math.min(top, p.top);
111			bottom = Math.max(bottom, p.bottom);
112		}
113		var dx = Math.max(left - x, 0, x - right);
114		var dy = Math.max(top - y, 0, y - bottom);
115		return Math.sqrt(dx * dx + dy * dy);
116	}
117
118	function scheduleClose(g) {
119		if (closeTimer) return; // 已經排了就別重排,否則一直移動=永遠不會到點
120		closeTimer = setTimeout(function() {
121			closeTimer = null;
122			if (openGrp !== g) return; // 期間已經被別的路徑收掉/換人開了
123			if (hoverDist(g, lastX, lastY) <= HOVER_SLACK) return; // 指標又回到範圍內:不收
124			setGroup(g, false);
125		}, HOVER_CLOSE_MS);
126	}
127
128	function wireHover(g) {
129		g.addEventListener('mouseenter', function() {
130			cancelClose();
131			closeGroups(g);
132			setGroup(g, true);
133		});
134		// 保險絲而已:實際收不收由計時器到點時的座標決定(見上面的說明)
135		g.addEventListener('mouseleave', function() {
136			scheduleClose(g);
137		});
138	}
139
140	if (hoverable) {
141		// 一支全域 mousemove(沒有面板開著就立刻 return,不量任何版面)
142		doc.addEventListener('mousemove', function(e) {
143			lastX = e.clientX;
144			lastY = e.clientY;
145			var g = openGrp;
146			if (!g) return;
147			var d = hoverDist(g, lastX, lastY);
148			if (d <= HOVER_SLACK) cancelClose(); // 還在(或剛好貼著)選單上
149			else if (d > HOVER_FAR) { cancelClose(); setGroup(g, false); } // 明確離開
150			else scheduleClose(g); // 附近徘徊/過衝:給人修正回來的時間
151		});
152		// 指標移出整個視窗之後就不會再有 mousemove,座標會停在最後一次(可能還在範圍內)
153		// —— 那樣計時器到點會判「不收」而卡在展開。把座標推到無限遠再排一次。
154		doc.documentElement.addEventListener('mouseleave', function() {
155			lastX = lastY = -1e5;
156			if (openGrp) scheduleClose(openGrp);
157		});
158	}
159
160	// 綁定既有的分組(使用者選單之後由 paintUser() 呼叫同一支 wireGroup)
161	groups.slice().forEach(wireGroup);
162
163	/* ---------- 手機抽屜 ---------- */
164	if (burger && drawer) {
165		burger.addEventListener('click', function() {
166			var open = burger.getAttribute('aria-expanded') !== 'true';
167			burger.setAttribute('aria-expanded', String(open));
168			drawer.hidden = !open;
169		});
170	}
171
172	/* ---------- Esc:收起並把焦點還給觸發者 ---------- */
173	doc.addEventListener('keydown', function(e) {
174		if (e.key !== 'Escape' && e.keyCode !== 27) return;
175		var opened = nav.querySelector('.sn-gbtn[aria-expanded="true"]');
176		closeGroups(null);
177		if (burger && drawer && burger.getAttribute('aria-expanded') === 'true') {
178			burger.setAttribute('aria-expanded', 'false');
179			drawer.hidden = true;
180			if (!opened) burger.focus();
181		}
182		if (opened) opened.focus();
183	});
184
185	/* ---------- 索引 bar 的提示文字 ---------- */
186	// 提示是蓋在輸入框上的 span(placeholder 沒辦法靠容器查詢換長短版),有字就收起。
187	var box = nav.querySelector('.sn-search'),
188		q = nav.querySelector('.sn-q');
189	if (box && q) {
190		var sync = function() { box.className = box.className.replace(/\s*sn-typing/g, '') + (q.value ? ' sn-typing' : ''); };
191		q.addEventListener('input', sync);
192		sync(); // 上一頁返回時瀏覽器可能已經還原了輸入內容
193	}
194
195	/* ---------- 登入狀態:注水(AUTH_PLAN §2-2)---------- */
196	// 導覽的 HTML 是全站共用、可快取的 —— 它對每個人都一樣。「你是誰」由 /api/me 回答,
197	// 在這裡填進 SSR 留下的 .sn-user 空位。**頁面本身永遠不因人而異**,快取才守得住。
198	//
199	// 閃爍怎麼消:上一次的**顯示資料**(名字/頭像/id,⚠ 沒有任何憑證)記在 localStorage,
200	// 進站先照著畫,/api/me 回來再校正;回 login:false 就清掉。
201	var userSlot = nav.querySelector('.sn-user');
202	var drawerSlot = doc.querySelector('.sn-duser');
203	var ME_KEY = 'psmud.me.v1';
204
205	function el(tag, cls, text) {
206		var n = doc.createElement(tag);
207		if (cls) n.className = cls;
208		// ⚠ 名字一律走 textContent。/api/me 送的是純文字(色碼已剝掉),
209		// 但那是別人可以在遊戲裡改的字串 —— 不要給它任何變成標記的機會。
210		if (text != null) n.textContent = text;
211		return n;
212	}
213
214	function backHere() {
215		return encodeURIComponent(location.pathname + location.search);
216	}
217
218	function link(href, text) {
219		var a = el('a', null, text);
220		a.href = href;
221		return a;
222	}
223
224	// 登出是**真表單**:沒有 JS 的人看不到這個選單,但有 JS 的人按的也該是同一條路
225	// (POST + SameSite=Lax 擋 CSRF)。⚠ 不可以做成 GET 連結,會被預抓誤觸發。
226	function logoutForm(cls) {
227		var f = doc.createElement('form');
228		f.method = 'post';
229		f.action = '/logout';
230		var h = doc.createElement('input');
231		h.type = 'hidden';
232		h.name = 'back';
233		h.value = location.pathname + location.search;
234		var b = el('button', cls || 'sn-logout-btn', '登出');
235		b.type = 'submit';
236		f.appendChild(h);
237		f.appendChild(b);
238		// ⚠ 送出前先把顯示快取清掉:登出後伺服器 302 回原頁,那一頁的 sitenav 會先讀
239		// localStorage「上次的樣子」把名字畫出來,等 /api/me 回來才換掉
240		// —— 使用者會看到自己的名字閃一下才消失。清掉就沒有那一下。
241		// (⚠ 這是**送出時**清,不是登出成功後清:表單一送出頁面就走了,沒有「之後」。
242		//   萬一登出失敗,下次 /api/me 會把它補回來,不會卡在錯的狀態。)
243		f.addEventListener('submit', function() {
244			remember(null);
245		});
246		return f;
247	}
248
249	function meLinks(me) {
250		var out = [];
251		if (me.hasCard) out.push(link('/player/' + me.id, '我的名片'));
252		out.push(link('/board/author/' + me.id, '我的發言'));
253		// 錨點由 /api/me 給(hero-<id> 或 ev-<id>),不要在前端自己拼 ——
254		// 「在滿級名錄裡」與「只在大事記裡」跳的地方不一樣,那個判斷在伺服器端
255		if (me.chronicle) out.push(link('/library/chronicle' +
256			(me.heroAnchor ? '#' + me.heroAnchor : ''), '我的英雄牆'));
257		/* 站務後台(2026-08-22):選單裡唯一不是給玩家看的項目,所以擺最後、CSS 用一條線隔開。
258		   `me.admin` 只有 admin 的 /api/me 才會帶 —— 這顆旗標決定的是「畫不畫這條連結」,
259		   **不是權限**:被人在 localStorage 裡改成 true,也只是多看到一條點進去被擋的連結。 */
260		if (me.admin) {
261			var ad = link('/admin', '站務後台');
262			ad.className = 'sn-admin';
263			out.push(ad);
264		}
265		return out;
266	}
267
268	function avatar(me) {
269		if (!me.img) return null;
270		var img = doc.createElement('img');
271		img.className = 'sn-avatar';
272		img.src = me.img;
273		img.alt = '';
274		img.width = 24;
275		img.height = 24;
276		return img;
277	}
278
279	// 註:這裡曾經有一支 hideLoginItems() —— 登入後把選單裡那條「登入」藏起來。
280	// nav-data 的那一項 2026-08-08 已改成 hidden(入口統一在這個空位),
281	// 選單裡根本不會再出現「登入」,所以那支跟著退場了。
282	function paintAnon() {
283		// 與 SSR 出來的那條連結一樣,只是補上 ?back= —— 那是 per-request 的東è¥
283¿,
284		// 伺服器端放不進去(導覽 HTML 是全站共用的)。
285		var mk = function() {
286			var a = link('/login?back=' + backHere(), '登入');
287			a.className = 'sn-login';
288			return a;
289		};
290		if (userSlot) {
291			userSlot.setAttribute('data-state', 'anon');
292			userSlot.innerHTML = '';
293			userSlot.appendChild(mk());
294		}
295		if (drawerSlot) {
296			drawerSlot.innerHTML = '';
297			drawerSlot.appendChild(mk());
298		}
299	}
300
301	function paintMe(me) {
302		if (userSlot) {
303			userSlot.setAttribute('data-state', 'me');
304			userSlot.innerHTML = '';
305			// **重用 .sn-grp 那一套**:鍵盤、aria-expanded、點外面收起全都是現成的
306			var grp = el('div', 'sn-grp sn-umenu');
307			var btn = doc.createElement('button');
308			btn.type = 'button';
309			btn.className = 'sn-gbtn';
310			btn.setAttribute('aria-expanded', 'false');
311			btn.setAttribute('aria-controls', 'sn-p-user');
312			var av = avatar(me);
313			if (av) btn.appendChild(av);
314			btn.appendChild(el('span', 'sn-uname', me.name));
315			var car = el('span', 'sn-car', '▾');
316			car.setAttribute('aria-hidden', 'true');
317			btn.appendChild(car);
318
319			var panel = doc.createElement('ul');
320			panel.className = 'sn-panel';
321			panel.id = 'sn-p-user';
322			panel.hidden = true;
323			meLinks(me).forEach(function(a) {
324				var li = doc.createElement('li');
325				li.appendChild(a);
326				panel.appendChild(li);
327			});
328			var li2 = doc.createElement('li');
329			li2.appendChild(logoutForm());
330			panel.appendChild(li2);
331
332			grp.appendChild(btn);
333			grp.appendChild(panel);
334			userSlot.appendChild(grp);
335			// ⚠ 一定要呼叫這支,否則這個按鈕**完全沒有反應而且不報錯**(見 wireGroup 的說明)
336			wireGroup(grp);
337		}
338
339		if (drawerSlot) {
340			drawerSlot.innerHTML = '';
341			var who = el('div', 'sn-duser-me');
342			var av2 = avatar(me);
343			if (av2) who.appendChild(av2);
344			who.appendChild(el('span', 'sn-uname', me.name));
345			drawerSlot.appendChild(who);
346			var ul = doc.createElement('ul');
347			ul.className = 'sn-dlinks';
348			meLinks(me).forEach(function(a) {
349				var li = doc.createElement('li');
350				li.appendChild(a);
351				ul.appendChild(li);
352			});
353			var li3 = doc.createElement('li');
354			li3.appendChild(logoutForm());
355			ul.appendChild(li3);
356			drawerSlot.appendChild(ul);
357		}
358	}
359
360	function cached() {
361		try {
362			return JSON.parse(window.localStorage.getItem(ME_KEY) || 'null');
363		} catch (e) {
364			return null;
365		}
366	}
367
368	function remember(me) {
369		try {
370			if (me) window.localStorage.setItem(ME_KEY, JSON.stringify(me));
371			else window.localStorage.removeItem(ME_KEY);
372		} catch (e) {}
373	}
374
375	if (userSlot || drawerSlot) {
376		var prev = cached();
377		if (prev && prev.id) paintMe(prev); // 先照上次的畫,消除「登入 → 名字」的閃爍
378		else paintAnon(); // 補上 ?back=(SSR 的那條連結沒有,那是 per-request 的東è¥
378¿)
379
380		/* 多分頁同步:`storage` 事件**只會在其他分頁觸發**(改的那一頁自己收不到),
381		   剛好就是我們要的 —— A 分頁登出/登入,B 分頁不必等到下次載入或下次 /api/me
382		   才發現狀態變了。沒有這一段的話,B 分頁會一直顯示已經失效的名字。 */
383		window.addEventListener('storage', function(e) {
384			if (e.key !== ME_KEY) return;
385			var v = null;
386			try {
387				v = e.newValue ? JSON.parse(e.newValue) : null;
388			} catch (x) {}
389			if (v && v.id) paintMe(v);
390			else paintAnon();
391		});
392
393		// XHR 而不是 fetch:這支要掛在每一頁,維持與檔案其餘部分同樣的相容水準
394		var xhr = new XMLHttpRequest();
395		xhr.open('GET', '/api/me', true);
396		xhr.onreadystatechange = function() {
397			if (xhr.readyState !== 4) return;
398			var me = null;
399			try {
400				me = JSON.parse(xhr.responseText);
401			} catch (e) {}
402			if (!me || xhr.status !== 200) return; // 網路壞掉:維持現狀,不要把人「畫成登出」
403			if (me.login) {
404				var keep = {
405					id: me.id,
406					name: me.name,
407					img: me.img || '',
408					hasCard: !!me.hasCard,
409					chronicle: !!me.chronicle,
410					heroAnchor: me.heroAnchor || '',
411					admin: !!me.admin // ⚠ 這是**白名單**複製:漏掉的欄位在下次載入的快取版會消失
412				};
413				remember(keep);
414				paintMe(keep);
415			} else {
416				remember(null);
417				paintAnon();
418			}
419		};
420		xhr.send();
421	}
422
423	/* ---------- 目前所在頁 ---------- */
424	// 刻意不在伺服器端標:那會讓導覽 HTML 因頁而異、失去只組一次的快取價值。
425	// 歸屬邏輯用 nav-data 的 current()(取最長前綴,所以 /library/item/褐藻戰衣 會歸「物品資料」)。
426	var NAV = window.PS_NAV;
427	if (!NAV) return;
428	var cur = NAV.current(location.pathname);
429	if (!cur) return;
430	var sel = '[href="' + cur.href + '"]';
431	[].slice.call(doc.querySelectorAll('.site-nav ' + sel + ', .sn-drawer ' + sel))
432		.forEach(function(a) { a.setAttribute('aria-current', 'page'); });
433	var g = NAV.groupOf(cur);
434	if (g) {
435		var panel = doc.getElementById('sn-p-' + g.id);
436		if (panel && panel.previousElementSibling) {
437			panel.previousElementSibling.className += ' sn-active';
438		}
439	}
440})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.