main
js 105 lines 2.93 KB
Raw
1 import { Controller } from "@hotwired/stimulus"
2
3 // GitHub-style line selection for the blob view.
4 //
5 // click a line number -> #L120
6 // shift-click another -> #L120-L140 (range, in either direction)
7 // load with a #L.. hash -> highlight + scroll into view
8 // "copy permalink" action -> SHA-pinned URL + the current selection
9 //
10 // Markup contract:
11 // <table data-controller="line-selection"
12 // data-line-selection-permalink-base-value="/u/r/blob/<sha>/path">
13 // <tr id="L1" data-line-selection-target="row"> … <a data-action="…">1</a> …
14 export default class extends Controller {
15 static targets = ["row"]
16 static values = { permalinkBase: String }
17
18 connect() {
19 this.start = null
20 this.end = null
21 this.#applyFromHash()
22 this._onHashChange = () => this.#applyFromHash()
23 window.addEventListener("hashchange", this._onHashChange)
24 }
25
26 disconnect() {
27 window.removeEventListener("hashchange", this._onHashChange)
28 }
29
30 // Bound to each line-number anchor via data-action="line-selection#select".
31 select(event) {
32 event.preventDefault()
33 const line = Number(event.params.line)
34 if (!line) return
35
36 if (event.shiftKey && this.start) {
37 this.end = line
38 } else {
39 this.start = line
40 this.end = line
41 }
42 this.#render()
43 this.#updateHash()
44 }
45
46 copyPermalink(event) {
47 event.preventDefault()
48 const base = this.permalinkBaseValue
49 if (!base) return
50 const origin = window.location.origin
51 const url = `${origin}${base}${this.#hash()}`
52 navigator.clipboard?.writeText(url).catch(() => {})
53 this.#flash(event.currentTarget)
54 }
55
56 #applyFromHash() {
57 const m = window.location.hash.match(/^#L(\d+)(?:-L?(\d+))?$/)
58 if (!m) return
59 this.start = Number(m[1])
60 this.end = m[2] ? Number(m[2]) : this.start
61 this.#render()
62 const first = this.#row(Math.min(this.start, this.end))
63 if (first) first.scrollIntoView({ block: "center" })
64 }
65
66 #render() {
67 const [lo, hi] = this.#ordered()
68 this.rowTargets.forEach((row) => {
69 const n = this.#lineOf(row)
70 row.classList.toggle("line-selected", lo !== null && n >= lo && n <= hi)
71 })
72 }
73
74 #updateHash() {
75 history.replaceState(null, "", this.#hash())
76 }
77
78 #hash() {
79 const [lo, hi] = this.#ordered()
80 if (lo === null) return ""
81 return lo === hi ? `#L${lo}` : `#L${lo}-L${hi}`
82 }
83
84 #ordered() {
85 if (!this.start) return [null, null]
86 return [Math.min(this.start, this.end), Math.max(this.start, this.end)]
87 }
88
89 #row(line) {
90 return this.rowTargets.find((r) => this.#lineOf(r) === line)
91 }
92
93 #lineOf(row) {
94 return Number(row.dataset.line ?? row.id.replace(/^L/, ""))
95 }
96
97 #flash(el) {
98 if (!el) return
99 const original = el.dataset.originalLabel ?? el.textContent
100 el.dataset.originalLabel = original
101 el.textContent = "Copied"
102 clearTimeout(this._t)
103 this._t = setTimeout(() => { el.textContent = original }, 1500)
104 }
105 }