A vibe coded tangled fork which supports pijul.
1{{ define "fragments/line-quote-button" }}
2<button
3 id="line-quote-btn"
4 type="button"
5 aria-label="Quote line in comment"
6 class="hidden fixed z-50 p-0.5 rounded bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer shadow-sm transition-opacity opacity-0 flex flex-col items-start"
7 style="pointer-events: none;"
8>
9 {{ i "message-square-quote" "w-3.5 h-3.5" }}
10 <span id="line-quote-btn-end" class="hidden mt-auto rotate-180 opacity-50">
11 {{ i "message-square-quote" "w-3.5 h-3.5" }}
12 </span>
13</button>
14<script>
15 (() => {
16 const btn = document.getElementById('line-quote-btn');
17 if (!btn) return;
18 const btnEnd = document.getElementById('line-quote-btn-end');
19
20 const textarea = () =>
21 document.getElementById('pull-comment-textarea')
22 || document.getElementById('comment-textarea');
23
24 const lineOf = (el) =>
25 el?.closest?.('span[id*="-O"]')
26 || el?.closest?.('span[id*="-N"]');
27
28 const anchorOf = (el) => {
29 const link = el.querySelector('a[href^="#"]');
30 return link ? link.getAttribute('href').slice(1) : el.id || null;
31 };
32
33 const fileOf = (el) => {
34 const d = el.closest('details[id^="file-"]');
35 return d ? d.id.replace(/^file-/, '') : null;
36 };
37
38 const lineNumOf = (el) => anchorOf(el)?.match(/(\d+)(?:-[ON]?\d+)?$/)?.[1];
39
40 const columnOf = (el) => el.closest('.flex-col');
41
42 const linesInColumn = (col) =>
43 Array.from(col.querySelectorAll('span[id*="-O"], span[id*="-N"]'))
44 .filter(s => s.querySelector('a[href^="#"]'));
45
46 let dragLines = null;
47
48 const rangeBetween = (a, b) => {
49 const col = columnOf(a);
50 if (!col || col !== columnOf(b)) return [];
51 const all = dragLines || linesInColumn(col);
52 const ai = all.indexOf(a);
53 const bi = all.indexOf(b);
54 if (ai === -1 || bi === -1) return [];
55 return all.slice(Math.min(ai, bi), Math.max(ai, bi) + 1);
56 };
57
58 const clearHl = (cls) =>
59 document.querySelectorAll(`.${cls}`).forEach(el => el.classList.remove(cls));
60
61 const applyHl = (a, b, cls) => {
62 clearHl(cls);
63 const sel = rangeBetween(a, b);
64 sel.forEach(el => el.classList.add(cls));
65 return sel;
66 };
67
68 const highlightFromHash = () => {
69 clearHl('line-range-hl');
70 const hash = decodeURIComponent(window.location.hash.slice(1));
71 if (!hash) return;
72 const parts = hash.split('~');
73 const startEl = document.getElementById(parts[0]);
74
75 if (!startEl) {
76 const params = new URLSearchParams(window.location.search);
77 const hasCombined = parts.some(p => /-O\d+-N\d+$/.test(p));
78 if (hasCombined && params.get('diff') !== 'unified') {
79 params.set('diff', 'unified');
80 window.location.replace(
81 `${window.location.pathname}?${params}${window.location.hash}`
82 );
83 }
84 return;
85 }
86
87 const endEl = parts.length === 2 ? document.getElementById(parts[1]) : startEl;
88 if (!endEl) return;
89
90 const details = startEl.closest('details');
91 if (details) details.open = true;
92
93 applyHl(startEl, endEl, 'line-range-hl');
94 requestAnimationFrame(() =>
95 startEl.scrollIntoView({ behavior: 'smooth', block: 'center' }));
96 };
97
98 if (document.readyState === 'loading') {
99 document.addEventListener('DOMContentLoaded', highlightFromHash);
100 } else {
101 highlightFromHash();
102 }
103 window.addEventListener('hashchange', highlightFromHash);
104
105 let dragging = false;
106 let dragAnchor = null;
107 let dragCurrent = null;
108 let hoverTarget = null;
109
110 const showBtn = (lineEl) => {
111 if (!textarea() || !anchorOf(lineEl)) return;
112 const rect = lineEl.getBoundingClientRect();
113 Object.assign(btn.style, {
114 top: `${rect.top + rect.height / 2 - btn.offsetHeight / 2}px`,
115 left: `${rect.left + 4}px`,
116 height: '',
117 opacity: '1',
118 pointerEvents: 'auto',
119 });
120 btn.classList.remove('hidden');
121 };
122
123 const hideBtn = () => {
124 if (dragging) return;
125 Object.assign(btn.style, { opacity: '0', pointerEvents: 'none', height: '' });
126 btnEnd.classList.add('hidden');
127 setTimeout(() => { if (btn.style.opacity === '0') btn.classList.add('hidden'); }, 150);
128 };
129
130 const stretchBtn = (a, b) => {
131 const aRect = a.getBoundingClientRect();
132 const bRect = b.getBoundingClientRect();
133 const top = Math.min(aRect.top, bRect.top);
134 const bottom = Math.max(aRect.bottom, bRect.bottom);
135 const multiLine = a !== b;
136 Object.assign(btn.style, {
137 top: `${top}px`,
138 left: `${aRect.left + 4}px`,
139 height: `${bottom - top}px`,
140 });
141 if (multiLine) { btnEnd.classList.remove('hidden'); }
142 else { btnEnd.classList.add('hidden'); }
143 };
144
145 document.addEventListener('mouseover', (e) => {
146 if (dragging || e.target === btn || btn.contains(e.target)) return;
147 const el = lineOf(e.target);
148 if (el && el !== hoverTarget) { hoverTarget = el; showBtn(el); }
149 });
150
151 document.addEventListener('mouseout', (e) => {
152 if (dragging) return;
153 const el = lineOf(e.target);
154 if (!el || lineOf(e.relatedTarget) === el || e.relatedTarget === btn || btn.contains(e.relatedTarget)) return;
155 hoverTarget = null;
156 hideBtn();
157 });
158
159 btn.addEventListener('mouseleave', (e) => {
160 if (!dragging && !lineOf(e.relatedTarget)) { hoverTarget = null; hideBtn(); }
161 });
162
163 btn.addEventListener('mousedown', (e) => {
164 if (e.button !== 0 || !hoverTarget) return;
165 e.preventDefault();
166 dragging = true;
167 dragAnchor = dragCurrent = hoverTarget;
168 const col = columnOf(hoverTarget);
169 dragLines = col ? linesInColumn(col) : null;
170 applyHl(dragAnchor, dragCurrent, 'line-quote-hl');
171 btn.style.pointerEvents = 'none';
172 document.body.style.userSelect = 'none';
173 });
174
175 document.addEventListener('mousemove', (e) => {
176 if (!dragging) return;
177 const el = lineOf(document.elementFromPoint(e.clientX, e.clientY));
178 if (!el || el === dragCurrent) return;
179 if (columnOf(el) !== columnOf(dragAnchor)) return;
180 dragCurrent = el;
181 applyHl(dragAnchor, dragCurrent, 'line-quote-hl');
182 stretchBtn(dragAnchor, dragCurrent);
183 });
184
185 document.addEventListener('mouseup', () => {
186 if (!dragging) return;
187 dragging = false;
188 document.body.style.userSelect = '';
189
190 const selected = rangeBetween(dragAnchor, dragCurrent);
191 const ta = textarea();
192 if (ta && selected.length > 0) {
193 const first = selected[0];
194 const last = selected[selected.length - 1];
195 const fNum = lineNumOf(first);
196 const firstAnchor = anchorOf(first);
197
198 if (fNum && firstAnchor) {
199 const file = fileOf(first);
200 const lNum = lineNumOf(last);
201 const lastAnchor = anchorOf(last);
202
203 const label = selected.length === 1
204 ? (file ? `${file}:${fNum}` : `L${fNum}`)
205 : (file ? `${file}:${fNum}-${lNum}` : `L${fNum}-${lNum}`);
206
207 const fragment = selected.length === 1
208 ? firstAnchor
209 : `${firstAnchor}~${lastAnchor}`;
210
211 const md = `[\`${label}\`](${window.location.pathname}${window.location.search}#${fragment})`;
212
213 const { selectionStart: s, selectionEnd: end, value } = ta;
214 const before = value.slice(0, s);
215 const after = value.slice(end);
216 let pre = '', suf = '';
217 if (s === end && before.length > 0) {
218 const cur = before.slice(before.lastIndexOf('\n') + 1);
219 if (cur.length > 0) {
220 const nextNl = after.indexOf('\n');
221 const rest = nextNl === -1 ? after : after.slice(0, nextNl);
222 if (rest.trim().length === 0) { pre = '\n'; }
223 else { pre = before.endsWith(' ') ? '' : ' '; suf = after.startsWith(' ') ? '' : ' '; }
224 }
225 }
226 ta.value = before + pre + md + suf + after;
227 ta.selectionStart = ta.selectionEnd = s + pre.length + md.length + suf.length;
228 ta.focus();
229 ta.dispatchEvent(new Event('input', { bubbles: true }));
230 }
231 }
232
233 clearHl('line-quote-hl');
234 dragLines = null;
235 dragAnchor = dragCurrent = hoverTarget = null;
236 hideBtn();
237 });
238
239 btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); });
240
241 const cancelDrag = () => {
242 if (!dragging) return;
243 dragging = false;
244 document.body.style.userSelect = '';
245 clearHl('line-quote-hl');
246 dragLines = null;
247 dragAnchor = dragCurrent = hoverTarget = null;
248 hideBtn();
249 };
250 window.addEventListener('blur', cancelDrag);
251 })();
252</script>
253{{ end }}