ework-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,571 @@
1
+ "use strict";
2
+ (function () {
3
+ // Initial data is in a <script type="application/json" id="initial-data"> block,
4
+ // NOT an inline executable script — CSP script-src 'self' blocks inline scripts, so
5
+ // a plain <script>window.__INITIAL__=...</script> would be stripped and we'd bail.
6
+ const initialEl = document.getElementById("initial-data");
7
+ const P = initialEl ? JSON.parse(initialEl.textContent) : null;
8
+ if (!P) return;
9
+ const MAX_DOM = 300;
10
+ const POLL_MS = 5000;
11
+ const NEW_FADE_MS = 4500;
12
+
13
+ // Reverse-chronological: items[0] = newest (top), last = oldest (bottom).
14
+ // P.commentSort drives display order: 'desc' (default) renders items as-is;
15
+ // 'asc' renders reversed so oldest sits at top. Internal state stays canonical DESC.
16
+ const commentSort = P.commentSort === "asc" ? "asc" : "desc";
17
+ const state = {
18
+ items: P.comments.slice(),
19
+ owner: P.owner,
20
+ repo: P.repo,
21
+ number: P.number,
22
+ totalComments: P.totalComments,
23
+ oldestPage: P.currentPage,
24
+ hasOlder: P.hasOlder,
25
+ sinceISO: P.sinceISO,
26
+ seenIds: new Set(P.comments.map((c) => c.id)),
27
+ newIds: new Set(),
28
+ booted: false,
29
+ };
30
+
31
+ const $ = (id) => document.getElementById(id);
32
+ const itemsEl = $("items");
33
+ const countEl = $("count");
34
+ const olderWrap = $("loadOlderWrap");
35
+ const olderBtn = $("loadOlder");
36
+ const composer = $("composer");
37
+ const composerInput = $("composerInput");
38
+ const composerSubmit = $("composerSubmit");
39
+ const composerClose = $("composerClose");
40
+ const composerFile = $("composerFile");
41
+ const descToggle = $("descToggle");
42
+
43
+ // Track the last text selection per comment root: clicking 🔊 clears the live
44
+ // selection, so we remember the selection's Range and use it when the button fires.
45
+ let ttsSelRoot = null;
46
+ let ttsSelRange = null;
47
+ document.addEventListener("selectionchange", () => {
48
+ const sel = window.getSelection();
49
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
50
+ const r = sel.getRangeAt(0);
51
+ const n = r.startContainer;
52
+ const el = n.nodeType === 3 ? n.parentElement : n;
53
+ const cb = el && el.closest ? el.closest(".card-b") : null;
54
+ if (cb) { ttsSelRoot = cb; ttsSelRange = r.cloneRange(); }
55
+ });
56
+ const issueDesc = $("issueDesc");
57
+ const toLatest = ensureToLatest();
58
+
59
+ function ensureToLatest() {
60
+ const b = document.createElement("button");
61
+ b.type = "button";
62
+ b.textContent = "↑ 最新";
63
+ const s = b.style;
64
+ s.position = "fixed";
65
+ s.right = "14px";
66
+ s.bottom = "14px";
67
+ s.zIndex = "50";
68
+ s.border = "1px solid var(--border)";
69
+ s.borderRadius = "999px";
70
+ s.padding = ".45rem .9rem";
71
+ s.background = "var(--bg-elev)";
72
+ s.color = "var(--text)";
73
+ s.boxShadow = "0 2px 10px rgba(0,0,0,.18)";
74
+ s.font = "600 13px system-ui,sans-serif";
75
+ s.cursor = "pointer";
76
+ b.classList.add("hidden");
77
+ b.addEventListener("click", () => window.scrollTo({ top: 0, behavior: "smooth" }));
78
+ document.body.appendChild(b);
79
+ return b;
80
+ }
81
+
82
+ function api(kind, params) {
83
+ const q = new URLSearchParams(params).toString();
84
+ return fetch(`/api/${state.owner}/${state.repo}/issues/${state.number}/${kind}?${q}`).then((r) =>
85
+ r.json()
86
+ );
87
+ }
88
+
89
+ function esc(s) {
90
+ return String(s == null ? "" : s)
91
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
92
+ .replace(/"/g, "&quot;");
93
+ }
94
+
95
+ function relTime(iso) {
96
+ const t = Date.parse(iso);
97
+ if (!Number.isFinite(t)) return "";
98
+ const d = (Date.now() - t) / 1000;
99
+ if (d < 60) return "刚刚";
100
+ if (d < 3600) return Math.floor(d / 60) + "分钟前";
101
+ if (d < 86400) return Math.floor(d / 3600) + "小时前";
102
+ if (d < 86400 * 30) return Math.floor(d / 86400) + "天前";
103
+ return new Date(t).toISOString().slice(0, 10);
104
+ }
105
+
106
+ const TAG_LABEL = { human: "👤", bot: "🤖", system: "⚙️" };
107
+
108
+ function cardHTML(c) {
109
+ const tag = c.tag || "human";
110
+ const label = TAG_LABEL[tag] || "👤";
111
+ const isNew = state.newIds.has(c.id) ? " new-marker" : "";
112
+ const rx = c.reactions && c.reactions.length
113
+ ? `<span class="rx">` + c.reactions.map(function (r) { return `<span class="rxc">${r.e}<span class="rxn">${r.n}</span></span>`; }).join("") + `</span>`
114
+ : "";
115
+ return (
116
+ `<div class="item item-${esc(tag)}${isNew}" id="comment-${c.id}" data-id="${c.id}">` +
117
+ `<div class="card"><div class="card-h">` +
118
+ `<span class="tag tag-${esc(tag)}">${label} ${esc(tag)}</span>` +
119
+ `<span class="who">${esc(c.login)}</span>` +
120
+ `<span class="when" data-ts="${esc(c.created_at)}" title="${esc(c.created_at)}">${relTime(c.created_at)}</span>` +
121
+ rx +
122
+ `<span class="card-actions">` +
123
+ `<button type="button" class="cbtn" data-cid="${c.id}" title="复制">📋</button>` +
124
+ `<button type="button" class="clink" data-cid="${c.id}" title="复制楼层链接">🔗</button>` +
125
+ `<button type="button" class="tbtn" data-cid="${c.id}" title="翻译">翻译</button>` +
126
+ `<button type="button" class="ttsstop" data-cid="${c.id}" title="停止朗读">⏹</button>` +
127
+ `<button type="button" class="ttsbtn" data-cid="${c.id}" title="朗读(选中起点)">🔊</button>` +
128
+ `</span>` +
129
+ `</div><div class="card-b">${c.body_html}</div></div></div>`
130
+ );
131
+ }
132
+
133
+ // Rebuilding innerHTML destroys the DOM nodes under an active text selection, which
134
+ // cancels it (felt as "select then immediately deselected" on active issues that poll
135
+ // every few seconds). When the user is mid-selection, skip the rebuild — state/cursor
136
+ // still update, the view refreshes on the next non-selecting render.
137
+ function hasTextSelection() {
138
+ const s = window.getSelection();
139
+ return !!s && !s.isCollapsed && s.toString().length > 0;
140
+ }
141
+
142
+ let trActive = 0;
143
+
144
+ function render(preserveScroll) {
145
+ const prevH = preserveScroll ? document.body.scrollHeight : 0;
146
+ const prevTop = preserveScroll ? window.scrollY : 0;
147
+ const over = state.items.length - MAX_DOM;
148
+ if (over > 0) state.items.splice(state.items.length - over, over);
149
+ if (trActive > 0 || (window.TTS && window.TTS.isActive()) || hasTextSelection()) return;
150
+ const displayItems = commentSort === "asc" ? state.items.slice().reverse() : state.items;
151
+ itemsEl.innerHTML = displayItems.map(cardHTML).join("");
152
+ if (countEl) countEl.textContent = `已加载 ${state.items.length} · 共 ${state.totalComments}`;
153
+ olderWrap.classList.toggle("hidden", !state.hasOlder);
154
+ if (preserveScroll && prevTop > 0) {
155
+ window.scrollTo(0, prevTop + (document.body.scrollHeight - prevH));
156
+ } else if (!state.booted) {
157
+ state.booted = true;
158
+ requestAnimationFrame(() => window.scrollTo(0, 0));
159
+ }
160
+ if (state.newIds.size) {
161
+ const ids = state.newIds;
162
+ state.newIds = new Set();
163
+ setTimeout(() => {
164
+ ids.forEach((id) => {
165
+ const el = itemsEl.querySelector(`.item[data-id="${id}"]`);
166
+ if (el) el.classList.remove("new-marker");
167
+ });
168
+ }, NEW_FADE_MS);
169
+ }
170
+ refreshToLatest();
171
+ }
172
+
173
+ function isNearTop() {
174
+ return window.scrollY <= 120;
175
+ }
176
+
177
+ function refreshToLatest() {
178
+ toLatest.classList.toggle("hidden", isNearTop());
179
+ }
180
+
181
+ function mergeFront(views, markNew) {
182
+ let added = 0;
183
+ for (const c of views) {
184
+ if (state.seenIds.has(c.id)) continue;
185
+ state.seenIds.add(c.id);
186
+ state.items.unshift(c);
187
+ if (markNew) state.newIds.add(c.id);
188
+ added++;
189
+ }
190
+ return added;
191
+ }
192
+
193
+ function mergeBack(views, markNew) {
194
+ let added = 0;
195
+ for (const c of views) {
196
+ if (state.seenIds.has(c.id)) continue;
197
+ state.seenIds.add(c.id);
198
+ state.items.push(c);
199
+ if (markNew) state.newIds.add(c.id);
200
+ added++;
201
+ }
202
+ return added;
203
+ }
204
+
205
+ olderBtn.addEventListener("click", async () => {
206
+ olderBtn.disabled = true;
207
+ olderBtn.textContent = "加载中…";
208
+ try {
209
+ const page = state.oldestPage - 1;
210
+ const data = await api("page", { page });
211
+ if (data.error) throw new Error(data.error);
212
+ mergeBack((data.comments || []).slice().reverse(), false);
213
+ state.oldestPage = page;
214
+ state.hasOlder = data.hasOlder !== false && page > 1;
215
+ render(false);
216
+ } catch (e) {
217
+ olderBtn.textContent = "重试加载更早";
218
+ } finally {
219
+ olderBtn.disabled = false;
220
+ olderBtn.textContent = "加载更早";
221
+ }
222
+ });
223
+
224
+ function legacyCopy(text) {
225
+ const ta = document.createElement("textarea");
226
+ ta.value = text;
227
+ ta.style.position = "fixed";
228
+ ta.style.opacity = "0";
229
+ document.body.appendChild(ta);
230
+ ta.select();
231
+ let ok = false;
232
+ try { ok = document.execCommand("copy"); } catch (e) {}
233
+ document.body.removeChild(ta);
234
+ return ok;
235
+ }
236
+ function copyText(text) {
237
+ if (navigator.clipboard && navigator.clipboard.writeText) {
238
+ return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
239
+ }
240
+ return Promise.resolve(legacyCopy(text));
241
+ }
242
+ // Shared action toolbar (📋 copy / 🔗 link / 翻译 translate): comments live in
243
+ // itemsEl (re-rendered each poll → delegate), issue description lives in .desc-wrap.
244
+ const inlineMd = (s) => esc(s)
245
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
246
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
247
+ const miniMd = (s) => {
248
+ const lines = s.split("\n");
249
+ let out = "", inUl = false, inCode = false, codeBuf = "", tableBuf = [];
250
+ const flushUl = () => { if (inUl) { out += "</ul>"; inUl = false; } };
251
+ const isRow = (l) => /^\|.+\|$/.test(l);
252
+ const isSep = (l) => /^\|[\s:|-]+\|$/.test(l);
253
+ const cell = (r) => r.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim());
254
+ const renderTable = (buf) => {
255
+ if (buf.length < 2 || !isSep(buf[1])) return buf.map(l => inlineMd(l) + "<br>").join("");
256
+ let h = "<table><thead><tr>" + cell(buf[0]).map(c => "<th>" + inlineMd(c) + "</th>").join("") + "</tr></thead>";
257
+ if (buf.length > 2) h += "<tbody>" + buf.slice(2).map(r => "<tr>" + cell(r).map(c => "<td>" + inlineMd(c) + "</td>").join("") + "</tr>").join("") + "</tbody>";
258
+ return h + "</table>";
259
+ };
260
+ const flushTable = () => { if (tableBuf.length) { out += renderTable(tableBuf); tableBuf = []; } };
261
+ for (const raw of lines) {
262
+ const line = raw.replace(/\s+$/, "");
263
+ if (/^```/.test(line)) {
264
+ flushUl(); flushTable();
265
+ if (inCode) { out += `<pre><code>${esc(codeBuf.replace(/\n$/, ""))}</code></pre>`; inCode = false; codeBuf = ""; }
266
+ else { inCode = true; }
267
+ continue;
268
+ }
269
+ if (inCode) { codeBuf += raw + "\n"; continue; }
270
+ if (isRow(line)) { flushUl(); tableBuf.push(line); continue; }
271
+ flushTable();
272
+ const hm = line.match(/^(#{1,6})\s+(.*)$/);
273
+ if (hm) { flushUl(); out += `<h${hm[1].length}>${inlineMd(hm[2])}</h${hm[1].length}>`; continue; }
274
+ const m = line.match(/^[-*]\s+(.*)$/);
275
+ if (m) { if (!inUl) { out += "<ul>"; inUl = true; } out += "<li>" + inlineMd(m[1]) + "</li>"; }
276
+ else { flushUl(); out += line ? inlineMd(line) + "<br>" : "<br>"; }
277
+ }
278
+ flushUl(); flushTable();
279
+ if (inCode) out += `<pre><code>${esc(codeBuf.replace(/\n$/, ""))}</code></pre>`;
280
+ return out;
281
+ };
282
+ function actionRoot(btn) {
283
+ const item = btn.closest(".item");
284
+ if (item) return item.querySelector(".card-b");
285
+ const dw = btn.closest(".desc-wrap");
286
+ if (dw) return dw.querySelector("#issueDesc") || dw.querySelector(".desc");
287
+ return null;
288
+ }
289
+
290
+ function doCopy(btn) {
291
+ const root = actionRoot(btn);
292
+ if (!root) return;
293
+ copyText(root.innerText).then(() => {
294
+ btn.textContent = "✅"; btn.classList.add("done");
295
+ setTimeout(() => { btn.textContent = "📋"; btn.classList.remove("done"); }, 1500);
296
+ });
297
+ }
298
+
299
+ async function doTranslate(btn) {
300
+ const root = actionRoot(btn);
301
+ if (!root) return;
302
+ if (btn.dataset.tr === "1") { btn.dataset.tr = "0"; btn.textContent = "翻译"; root.innerHTML = btn.dataset.orig; return; }
303
+ const text = root.innerText.trim();
304
+ if (!text) return;
305
+ if (!btn.dataset.orig) btn.dataset.orig = root.innerHTML;
306
+ btn.textContent = "⏳";
307
+ trActive++;
308
+ try {
309
+ const res = await fetch("/api/translate/stream", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });
310
+ if (!res.ok || !res.body) throw new Error("http " + res.status);
311
+ const reader = res.body.getReader();
312
+ const dec = new TextDecoder();
313
+ let buf = "", full = "", html = null;
314
+ for (;;) {
315
+ const { done, value } = await reader.read();
316
+ if (done) break;
317
+ buf += dec.decode(value, { stream: true });
318
+ const lines = buf.split("\n"); buf = lines.pop() || "";
319
+ for (const line of lines) {
320
+ const s = line.trim(); if (!s) continue;
321
+ let obj; try { obj = JSON.parse(s); } catch (e) { continue; }
322
+ if (obj.d) { full += obj.d; root.innerHTML = miniMd(full); }
323
+ else if (obj.h) { html = obj.h; }
324
+ else if (obj.e) { throw new Error(obj.e); }
325
+ }
326
+ }
327
+ if (buf.trim()) { try { const obj = JSON.parse(buf.trim()); if (obj.h) html = obj.h; } catch (e) {} }
328
+ if (html) root.innerHTML = html;
329
+ btn.dataset.tr = "1"; btn.textContent = "原文";
330
+ } catch (e) {
331
+ btn.textContent = "❌";
332
+ setTimeout(() => { btn.textContent = "翻译"; }, 1500);
333
+ } finally {
334
+ trActive--;
335
+ }
336
+ }
337
+
338
+ itemsEl.addEventListener("click", (e) => {
339
+ const link = e.target.closest(".clink");
340
+ if (link) {
341
+ const cid = link.dataset.cid;
342
+ const url = location.origin + location.pathname + "#" + (cid ? "comment-" + cid : "issueDesc");
343
+ copyText(url).then(() => {
344
+ link.textContent = "✅"; link.classList.add("done");
345
+ setTimeout(() => { link.textContent = "🔗"; link.classList.remove("done"); }, 1500);
346
+ });
347
+ return;
348
+ }
349
+ if (e.target.closest(".cbtn")) { doCopy(e.target.closest(".cbtn")); return; }
350
+ if (e.target.closest(".tbtn")) { doTranslate(e.target.closest(".tbtn")); return; }
351
+ });
352
+
353
+ itemsEl.addEventListener("click", (e) => {
354
+ if (e.target.closest(".ttsstop")) { e.stopPropagation(); if (window.TTS) window.TTS.stop(); return; }
355
+ const tb = e.target.closest(".ttsbtn");
356
+ if (!tb) return;
357
+ const item = tb.closest(".item");
358
+ if (!item) return;
359
+ const body = item.querySelector(".card-b");
360
+ if (!body) return;
361
+ let text = "";
362
+ if (ttsSelRoot === body && ttsSelRange) {
363
+ const r = ttsSelRange.cloneRange();
364
+ try { r.setEnd(body, body.childNodes.length); } catch (e2) {}
365
+ text = r.toString().trim();
366
+ }
367
+ if (!text) text = body.innerText.trim();
368
+ if (!text) return;
369
+ window.TTS.start({ text, btn: tb });
370
+ });
371
+
372
+ const descWrap = document.querySelector(".desc-wrap");
373
+ if (descWrap) {
374
+ descWrap.addEventListener("click", (e) => {
375
+ if (e.target.closest(".cbtn")) { doCopy(e.target.closest(".cbtn")); return; }
376
+ if (e.target.closest(".tbtn")) { doTranslate(e.target.closest(".tbtn")); return; }
377
+ const lk = e.target.closest(".clink");
378
+ if (lk) {
379
+ const url = location.origin + location.pathname + "#issueDesc";
380
+ copyText(url).then(() => { lk.textContent = "✅"; lk.classList.add("done"); setTimeout(() => { lk.textContent = "🔗"; lk.classList.remove("done"); }, 1500); });
381
+ return;
382
+ }
383
+ if (e.target.closest(".ttsstop")) { e.stopPropagation(); if (window.TTS) window.TTS.stop(); return; }
384
+ const tb = e.target.closest(".ttsbtn");
385
+ if (!tb) return;
386
+ const desc = document.getElementById("issueDesc");
387
+ if (!desc) return;
388
+ const text = desc.innerText.trim();
389
+ if (!text) return;
390
+ window.TTS.start({ text, btn: tb });
391
+ });
392
+ }
393
+
394
+ async function sendComment(action) {
395
+ const body = composerInput.value.trim();
396
+ const wantsToggle = action === "close" || action === "reopen";
397
+ if (!body && !wantsToggle) return;
398
+ const btn = action ? composerClose : composerSubmit;
399
+ const orig = btn ? btn.textContent : "";
400
+ if (btn) { btn.disabled = true; btn.textContent = action ? "处理中…" : "发送中…"; }
401
+ try {
402
+ const extra = action === "close" ? { close: true } : action === "reopen" ? { reopen: true } : {};
403
+ const data = await fetch(
404
+ `/api/${state.owner}/${state.repo}/issues/${state.number}/comment`,
405
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body, ...extra }) }
406
+ ).then((r) => r.json());
407
+ if (data.error) throw new Error(data.error);
408
+ if (data.closed || data.reopened) { composerInput.value = ""; window.location.reload(); return; }
409
+ const c = data.comment;
410
+ if (c && !state.seenIds.has(c.id)) {
411
+ state.seenIds.add(c.id);
412
+ state.newIds.add(c.id);
413
+ state.items.unshift(c);
414
+ }
415
+ if (c) state.sinceISO = c.created_at;
416
+ composerInput.value = "";
417
+ window.scrollTo(0, 0);
418
+ render(false);
419
+ } catch (e) {
420
+ alert("发送失败: " + (e && e.message ? e.message : e));
421
+ } finally {
422
+ if (btn) { btn.disabled = false; btn.textContent = orig; }
423
+ }
424
+ }
425
+
426
+ function insertAtCursor(el, text) {
427
+ const s = el.selectionStart != null ? el.selectionStart : el.value.length;
428
+ const e = el.selectionEnd != null ? el.selectionEnd : el.value.length;
429
+ el.value = el.value.slice(0, s) + text + el.value.slice(e);
430
+ el.selectionStart = el.selectionEnd = s + text.length;
431
+ el.focus();
432
+ }
433
+
434
+ async function uploadFiles(files) {
435
+ for (const f of files) {
436
+ composerSubmit.disabled = true;
437
+ composerSubmit.textContent = "上传中…";
438
+ try {
439
+ const fd = new FormData();
440
+ fd.set("attachment", f);
441
+ fd.set("name", f.name);
442
+ const data = await fetch(
443
+ `/api/${state.owner}/${state.repo}/issues/${state.number}/upload`,
444
+ { method: "POST", body: fd }
445
+ ).then((r) => r.json());
446
+ if (data.error) throw new Error(data.error);
447
+ if (data.markdown) insertAtCursor(composerInput, (composerInput.value ? "\n" : "") + data.markdown);
448
+ } catch (e) {
449
+ alert("上传失败: " + (e && e.message ? e.message : e));
450
+ } finally {
451
+ composerSubmit.disabled = false;
452
+ composerSubmit.textContent = "发送";
453
+ }
454
+ }
455
+ }
456
+
457
+ if (composerFile) {
458
+ composerFile.addEventListener("change", () => {
459
+ if (composerFile.files && composerFile.files.length) uploadFiles([...composerFile.files]);
460
+ composerFile.value = "";
461
+ });
462
+ }
463
+ if (composerInput) {
464
+ composerInput.addEventListener("paste", (e) => {
465
+ const items = e.clipboardData && e.clipboardData.items;
466
+ if (!items) return;
467
+ const files = [];
468
+ for (const it of items) if (it.kind === "file") { const f = it.getAsFile(); if (f) files.push(f); }
469
+ if (files.length) { e.preventDefault(); uploadFiles(files); }
470
+ });
471
+ // beforeunload: if there's unsent text, ask the browser to confirm refresh/close/navigate
472
+ // away (same guard native Gitea uses) so an accidental refresh doesn't lose a drafted comment.
473
+ window.addEventListener("beforeunload", (e) => {
474
+ if (composerInput.value.trim()) {
475
+ e.preventDefault();
476
+ e.returnValue = "";
477
+ }
478
+ });
479
+ }
480
+
481
+ if (composer) {
482
+ composer.addEventListener("submit", (e) => {
483
+ e.preventDefault();
484
+ sendComment();
485
+ });
486
+ composerInput.addEventListener("keydown", (e) => {
487
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
488
+ e.preventDefault();
489
+ sendComment();
490
+ }
491
+ });
492
+ if (composerClose) {
493
+ const closeLabel = composerClose.textContent;
494
+ let closeArmed = false;
495
+ let closeArmTimer = null;
496
+ const disarmClose = () => {
497
+ closeArmed = false;
498
+ clearTimeout(closeArmTimer);
499
+ composerClose.textContent = closeLabel;
500
+ composerClose.classList.remove("armed");
501
+ };
502
+ composerClose.addEventListener("click", () => {
503
+ const action = composerClose.dataset.action || "close";
504
+ if (action === "close" && !closeArmed) {
505
+ closeArmed = true;
506
+ composerClose.textContent = "⚠ 再次点击确认关闭";
507
+ composerClose.classList.add("armed");
508
+ clearTimeout(closeArmTimer);
509
+ closeArmTimer = setTimeout(disarmClose, 4000);
510
+ return;
511
+ }
512
+ disarmClose();
513
+ sendComment(action);
514
+ });
515
+ }
516
+ }
517
+
518
+ if (descToggle && issueDesc) {
519
+ descToggle.addEventListener("click", () => {
520
+ const collapsed = issueDesc.classList.toggle("collapsed");
521
+ descToggle.textContent = collapsed ? "显示详情 ▾" : "收起 ▴";
522
+ });
523
+ }
524
+
525
+ let pollTimer = null;
526
+ async function poll() {
527
+ try {
528
+ const data = await api("since", { since: state.sinceISO });
529
+ if (!data || data.error) return;
530
+ const views = data.comments || [];
531
+ if (!views.length) return;
532
+ mergeFront(views, true);
533
+ state.sinceISO = views[views.length - 1].created_at;
534
+ render(true);
535
+ } catch (_) {}
536
+ }
537
+
538
+ function startPoll() {
539
+ if (pollTimer) return;
540
+ pollTimer = setInterval(poll, POLL_MS);
541
+ document.addEventListener("visibilitychange", () => {
542
+ if (document.hidden) {
543
+ clearInterval(pollTimer);
544
+ pollTimer = null;
545
+ } else {
546
+ poll();
547
+ startPoll();
548
+ }
549
+ });
550
+ }
551
+
552
+ let scrollTick = false;
553
+ window.addEventListener("scroll", () => {
554
+ if (scrollTick) return;
555
+ scrollTick = true;
556
+ requestAnimationFrame(() => {
557
+ refreshToLatest();
558
+ scrollTick = false;
559
+ });
560
+ }, { passive: true });
561
+
562
+ setInterval(() => {
563
+ if (hasTextSelection()) return;
564
+ document.querySelectorAll(".when[data-ts]").forEach((el) => {
565
+ el.firstChild && (el.textContent = relTime(el.getAttribute("data-ts")));
566
+ });
567
+ }, 30000);
568
+
569
+ render(false);
570
+ startPoll();
571
+ })();
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#2185d0"/>
3
+ <path d="M18 20 h28 a5 5 0 0 1 5 5 v11 a5 5 0 0 1-5 5 H34 l-9 8 v-8 h-7 a5 5 0 0 1-5-5 V25 a5 5 0 0 1 5-5 z" fill="#fff"/>
4
+ <circle cx="27" cy="30" r="2.4" fill="#2185d0"/>
5
+ <circle cx="37" cy="30" r="2.4" fill="#2185d0"/>
6
+ </svg>
@@ -0,0 +1,103 @@
1
+ (() => {
2
+ "use strict";
3
+ const btn = document.getElementById("followBtn");
4
+ const dataEl = document.getElementById("file-data");
5
+ const code = document.querySelector("pre code");
6
+ if (!btn || !dataEl || !code) return;
7
+
8
+ let meta;
9
+ try { meta = JSON.parse(dataEl.textContent || "{}"); } catch { return; }
10
+ if (!meta.path) return;
11
+
12
+ let polling = false;
13
+ let timer = null;
14
+ const POLL_MS = 3000;
15
+ const MAX_ROWS = 3000;
16
+
17
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
18
+ const rowHTML = (n, t) => `<span class="ln"><span class="lnn">${n}</span><span class="lnc">${esc(t)}</span></span>`;
19
+
20
+ function nearBottom() { return window.innerHeight + window.scrollY >= document.body.scrollHeight - 80; }
21
+ // Skip while mid-selection: inserting rows cancels it; meta.size stays (no loss).
22
+ function hasTextSelection() {
23
+ const s = window.getSelection();
24
+ return !!s && !s.isCollapsed && s.toString().length > 0;
25
+ }
26
+
27
+ function flashNew(n) {
28
+ const orig = btn.textContent;
29
+ btn.textContent = `✨ +${n}`;
30
+ btn.classList.add("flash");
31
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove("flash"); }, 1500);
32
+ }
33
+
34
+ function trimRows() {
35
+ const rows = code.querySelectorAll("span.ln");
36
+ if (rows.length <= MAX_ROWS) return;
37
+ const drop = rows.length - MAX_ROWS;
38
+ if (meta.order === "desc") {
39
+ for (let i = rows.length - 1; i >= rows.length - drop; i--) rows[i]?.remove();
40
+ } else {
41
+ for (let i = 0; i < drop; i++) rows[i]?.remove();
42
+ }
43
+ }
44
+
45
+ async function poll() {
46
+ if (hasTextSelection()) return;
47
+ try {
48
+ const url = `/api/file/since?path=${encodeURIComponent(meta.path)}&after=${meta.size}`;
49
+ const r = await fetch(url, { headers: { "accept": "application/json" } });
50
+ if (!r.ok) return;
51
+ const d = await r.json();
52
+ if (!d || typeof d !== "object") return;
53
+ if (d.rotated) { window.location.reload(); return; }
54
+ if (!Array.isArray(d.rows) || d.rows.length === 0) return;
55
+ const prevScroll = window.scrollY;
56
+ const prevH = document.body.scrollHeight;
57
+ const wasAtBottom = nearBottom();
58
+ let count = 0;
59
+ for (const row of d.rows) {
60
+ meta.maxN = (meta.maxN || 0) + 1;
61
+ const html = rowHTML(meta.maxN, String(row.t ?? ""));
62
+ code.insertAdjacentHTML(meta.order === "desc" ? "afterbegin" : "beforeend", html);
63
+ count++;
64
+ }
65
+ const addedTopH = document.body.scrollHeight - prevH;
66
+ meta.size = d.size ?? meta.size;
67
+ trimRows();
68
+ flashNew(count);
69
+ // desc: prepended rows shift content down — compensate so the view stays put; pin
70
+ // to top only if already there. asc: appended below, follow only if at bottom.
71
+ if (meta.order === "desc") {
72
+ if (prevScroll <= 5) window.scrollTo(0, 0);
73
+ else window.scrollTo(0, prevScroll + addedTopH);
74
+ } else if (wasAtBottom) {
75
+ window.scrollTo(0, document.body.scrollHeight);
76
+ }
77
+ } catch (e) {
78
+ console.warn("file follow poll failed:", e);
79
+ }
80
+ }
81
+
82
+ function start() {
83
+ if (polling) return;
84
+ polling = true;
85
+ btn.textContent = "⏸ Stop";
86
+ btn.classList.add("on");
87
+ poll();
88
+ timer = setInterval(poll, POLL_MS);
89
+ }
90
+
91
+ function stop() {
92
+ if (!polling) return;
93
+ polling = false;
94
+ btn.textContent = "🔄 Follow";
95
+ btn.classList.remove("on");
96
+ if (timer) { clearInterval(timer); timer = null; }
97
+ }
98
+
99
+ btn.addEventListener("click", () => { polling ? stop() : start(); });
100
+
101
+ const params = new URLSearchParams(location.search);
102
+ if (meta.isLog && params.get("follow") !== "0") start();
103
+ })();