Sam Jeston
History
141 lines·
4.8 KB
·JavaScript
| 1 | // Small progressive enhancements. Every page works without this script. |
| 2 | (() => { |
| 3 | "use strict"; |
| 4 | |
| 5 | const copyText = async (text, button) => { |
| 6 | try { |
| 7 | await navigator.clipboard.writeText(text); |
| 8 | } catch { |
| 9 | const area = document.createElement("textarea"); |
| 10 | area.value = text; |
| 11 | document.body.appendChild(area); |
| 12 | area.select(); |
| 13 | document.execCommand("copy"); |
| 14 | area.remove(); |
| 15 | } |
| 16 | const original = button.innerHTML; |
| 17 | button.innerHTML = |
| 18 | '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>'; |
| 19 | button.classList.add("copied"); |
| 20 | setTimeout(() => { |
| 21 | button.innerHTML = original; |
| 22 | button.classList.remove("copied"); |
| 23 | }, 1500); |
| 24 | }; |
| 25 | |
| 26 | document.addEventListener("click", (event) => { |
| 27 | const target = event.target.closest("[data-copy], [data-copy-text]"); |
| 28 | if (target) { |
| 29 | event.preventDefault(); |
| 30 | const text = target.dataset.copyText ?? document.querySelector(target.dataset.copy)?.value ?? ""; |
| 31 | copyText(text, target); |
| 32 | return; |
| 33 | } |
| 34 | |
| 35 | const toggle = event.target.closest("[data-toggle]"); |
| 36 | if (toggle) { |
| 37 | const el = document.getElementById(toggle.dataset.toggle); |
| 38 | if (el) { |
| 39 | el.hidden = !el.hidden; |
| 40 | if (!el.hidden) el.querySelector("input, textarea")?.focus(); |
| 41 | } |
| 42 | return; |
| 43 | } |
| 44 | |
| 45 | const collapse = event.target.closest("[data-collapse]"); |
| 46 | if (collapse) { |
| 47 | collapse.closest(".diff-file")?.classList.toggle("collapsed"); |
| 48 | return; |
| 49 | } |
| 50 | |
| 51 | const cloneTab = event.target.closest("[data-clone-tab]"); |
| 52 | if (cloneTab) { |
| 53 | const panel = cloneTab.closest(".clone-panel"); |
| 54 | panel.querySelectorAll("[data-clone-tab]").forEach((t) => t.classList.toggle("selected", t === cloneTab)); |
| 55 | panel.querySelectorAll("[data-clone-pane]").forEach((p) => { |
| 56 | p.hidden = p.dataset.clonePane !== cloneTab.dataset.cloneTab; |
| 57 | }); |
| 58 | return; |
| 59 | } |
| 60 | |
| 61 | // Close open dropdowns when clicking elsewhere. |
| 62 | document.querySelectorAll("details.dropdown[open]").forEach((d) => { |
| 63 | if (!d.contains(event.target)) d.removeAttribute("open"); |
| 64 | }); |
| 65 | }); |
| 66 | |
| 67 | // Only one dropdown open at a time; focus the filter when opening. |
| 68 | document.querySelectorAll("details.dropdown").forEach((details) => { |
| 69 | details.addEventListener("toggle", () => { |
| 70 | if (!details.open) return; |
| 71 | document.querySelectorAll("details.dropdown[open]").forEach((other) => { |
| 72 | if (other !== details) other.removeAttribute("open"); |
| 73 | }); |
| 74 | details.querySelector("[data-filter]")?.focus(); |
| 75 | }); |
| 76 | }); |
| 77 | |
| 78 | document.addEventListener("keydown", (event) => { |
| 79 | if (event.key === "Escape") { |
| 80 | document.querySelectorAll("details.dropdown[open]").forEach((d) => { |
| 81 | d.removeAttribute("open"); |
| 82 | d.querySelector("summary")?.focus(); |
| 83 | }); |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | document.addEventListener("input", (event) => { |
| 88 | const filter = event.target.closest("[data-filter]"); |
| 89 | if (!filter) return; |
| 90 | const query = filter.value.trim().toLowerCase(); |
| 91 | filter |
| 92 | .closest(".dropdown-menu") |
| 93 | .querySelectorAll("[data-filter-item]") |
| 94 | .forEach((item) => { |
| 95 | item.hidden = query !== "" && !item.textContent.toLowerCase().includes(query); |
| 96 | }); |
| 97 | }); |
| 98 | |
| 99 | document.addEventListener("change", (event) => { |
| 100 | const select = event.target.closest("select[data-autosubmit]"); |
| 101 | if (select) select.form.submit(); |
| 102 | }); |
| 103 | |
| 104 | document.addEventListener("submit", (event) => { |
| 105 | const message = event.target.dataset.confirm; |
| 106 | if (message && !window.confirm(message)) event.preventDefault(); |
| 107 | }); |
| 108 | |
| 109 | // Highlight #L10 or #L10-L20 in file views. |
| 110 | const highlightLines = () => { |
| 111 | document.querySelectorAll("tr.highlighted").forEach((row) => row.classList.remove("highlighted")); |
| 112 | const match = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); |
| 113 | if (!match) return; |
| 114 | const start = Number(match[1]); |
| 115 | const end = Number(match[2] || match[1]); |
| 116 | for (let n = Math.min(start, end); n <= Math.max(start, end); n++) { |
| 117 | document.getElementById(`L${n}`)?.classList.add("highlighted"); |
| 118 | } |
| 119 | }; |
| 120 | |
| 121 | // Shift-click a line number to select a range. |
| 122 | let anchorLine = null; |
| 123 | document.addEventListener("click", (event) => { |
| 124 | const link = event.target.closest(".code-view .num a"); |
| 125 | if (!link) return; |
| 126 | const line = Number(link.getAttribute("href").slice(2)); |
| 127 | if (event.shiftKey && anchorLine) { |
| 128 | event.preventDefault(); |
| 129 | const [a, b] = [Math.min(anchorLine, line), Math.max(anchorLine, line)]; |
| 130 | history.replaceState(null, "", `#L${a}-L${b}`); |
| 131 | highlightLines(); |
| 132 | } else { |
| 133 | anchorLine = line; |
| 134 | } |
| 135 | }); |
| 136 | |
| 137 | window.addEventListener("hashchange", highlightLines); |
| 138 | highlightLines(); |
| 139 | const first = document.querySelector("tr.highlighted"); |
| 140 | if (first) first.scrollIntoView({ block: "center" }); |
| 141 | })(); |