This repository has no description
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('comment-textarea');
22
23 const lineOf = (el) =>
24 el?.closest?.('span[id*="-O"]')
25 || el?.closest?.('span[id*="-N"]');
26
27 const anchorOf = (el) => {
28 const link = el.querySelector('a[href^="#"]');
29 return link ? link.getAttribute('href').slice(1) : el.id || null;
30 };
31
32 const fileOf = (el) => {
33 const d = el.closest('details[id^="file-"]');
34 return d ? d.id.replace(/^file-/, '') : null;
35 };
36
37 const lineNumOf = (el) => anchorOf(el)?.match(/(\d+)(?:-[ON]?\d+)?$/)?.[1];
38
39 const columnOf = (el) => el.closest('.flex-col');
40
41 const linesInColumn = (col) =>
42 Array.from(col.querySelectorAll('span[id*="-O"], span[id*="-N"]'))
43 .filter(s => s.querySelector('a[href^="#"]'));
44
45 let dragLines = null;
46
47 const rangeBetween = (a, b) => {
48 const col = columnOf(a);
49 if (!col || col !== columnOf(b)) return [];
50 const all = dragLines || linesInColumn(col);
51 const ai = all.indexOf(a);
52 const bi = all.indexOf(b);
53 if (ai === -1 || bi === -1) return [];
54 return all.slice(Math.min(ai, bi), Math.max(ai, bi) + 1);
55 };
56
57 const clearHl = (cls) =>
58 document.querySelectorAll(`.${cls}`).forEach(el => el.classList.remove(cls));
59
60 const applyHl = (a, b, cls) => {
61 clearHl(cls);
62 const sel = rangeBetween(a, b);
63 sel.forEach(el => el.classList.add(cls));
64 return sel;
65 };
66
67 const highlightFromHash = () => {
68 clearHl('line-range-hl');
69 let hash;
70 try { hash = decodeURIComponent(window.location.hash.slice(1)); } catch (e) { return; }
71 if (!hash) return;
72 if (hash.startsWith('comment-') || hash.startsWith('round-')) return;
73 const parts = hash.split('~');
74 const startEl = document.getElementById(parts[0]);
75
76 if (!startEl) {
77 const params = new URLSearchParams(window.location.search);
78 const hasCombined = parts.some(p => /-O\d+-N\d+$/.test(p));
79 if (hasCombined && params.get('diff') !== 'unified') {
80 params.set('diff', 'unified');
81 window.location.replace(
82 `${window.location.pathname}?${params}${window.location.hash}`
83 );
84 }
85 return;
86 }
87
88 const endEl = parts.length === 2 ? document.getElementById(parts[1]) : startEl;
89 if (!endEl) return;
90
91 const details = startEl.closest('details');
92 if (details) details.open = true;
93
94 applyHl(startEl, endEl, 'line-range-hl');
95 requestAnimationFrame(() =>
96 startEl.scrollIntoView({ behavior: 'smooth', block: 'center' }));
97 };
98
99 if (document.readyState === 'loading') {
100 document.addEventListener('DOMContentLoaded', highlightFromHash);
101 } else {
102 highlightFromHash();
103 }
104 window.addEventListener('hashchange', highlightFromHash);
105
106 let dragging = false;
107 let dragAnchor = null;
108 let dragCurrent = null;
109 let hoverTarget = null;
110
111 const commentBtn = () =>
112 document.querySelector('[hx-get$="/comment"]:not(form *)');
113
114 const openCommentForm = () => {
115 const ta = textarea();
116 if (ta) return Promise.resolve(ta);
117 const trigger = commentBtn();
118 if (!trigger) return Promise.resolve(null);
119 trigger.click();
120 return new Promise(resolve => {
121 const handler = () => {
122 const ta = textarea();
123 if (!ta) return;
124 document.body.removeEventListener('htmx:afterSettle', handler);
125 clearTimeout(timer);
126 resolve(ta);
127 };
128 const timer = setTimeout(() => {
129 document.body.removeEventListener('htmx:afterSettle', handler);
130 resolve(null);
131 }, 5000);
132 document.body.addEventListener('htmx:afterSettle', handler);
133 });
134 };
135
136 const showBtn = (lineEl) => {
137 if ((!textarea() && !commentBtn()) || !anchorOf(lineEl)) return;
138 const rect = lineEl.getBoundingClientRect();
139 Object.assign(btn.style, {
140 top: `${rect.top + rect.height / 2 - btn.offsetHeight / 2}px`,
141 left: `${rect.left + 4}px`,
142 height: '',
143 opacity: '1',
144 pointerEvents: 'auto',
145 });
146 btn.classList.remove('hidden');
147 };
148
149 const hideBtn = () => {
150 if (dragging) return;
151 Object.assign(btn.style, { opacity: '0', pointerEvents: 'none', height: '' });
152 btnEnd.classList.add('hidden');
153 setTimeout(() => { if (btn.style.opacity === '0') btn.classList.add('hidden'); }, 150);
154 };
155
156 const stretchBtn = (a, b) => {
157 const aRect = a.getBoundingClientRect();
158 const bRect = b.getBoundingClientRect();
159 const top = Math.min(aRect.top, bRect.top);
160 const bottom = Math.max(aRect.bottom, bRect.bottom);
161 const multiLine = a !== b;
162 Object.assign(btn.style, {
163 top: `${top}px`,
164 left: `${aRect.left + 4}px`,
165 height: `${bottom - top}px`,
166 });
167 if (multiLine) { btnEnd.classList.remove('hidden'); }
168 else { btnEnd.classList.add('hidden'); }
169 };
170
171 document.addEventListener('mouseover', (e) => {
172 if (dragging || e.target === btn || btn.contains(e.target)) return;
173 const el = lineOf(e.target);
174 if (el && el !== hoverTarget) { hoverTarget = el; showBtn(el); }
175 });
176
177 document.addEventListener('mouseout', (e) => {
178 if (dragging) return;
179 const el = lineOf(e.target);
180 if (!el || lineOf(e.relatedTarget) === el || e.relatedTarget === btn || btn.contains(e.relatedTarget)) return;
181 hoverTarget = null;
182 hideBtn();
183 });
184
185 btn.addEventListener('mouseleave', (e) => {
186 if (!dragging && !lineOf(e.relatedTarget)) { hoverTarget = null; hideBtn(); }
187 });
188
189 btn.addEventListener('mousedown', (e) => {
190 if (e.button !== 0 || !hoverTarget) return;
191 e.preventDefault();
192 dragging = true;
193 dragAnchor = dragCurrent = hoverTarget;
194 const col = columnOf(hoverTarget);
195 dragLines = col ? linesInColumn(col) : null;
196 applyHl(dragAnchor, dragCurrent, 'line-quote-hl');
197 btn.style.pointerEvents = 'none';
198 document.body.style.userSelect = 'none';
199 });
200
201 document.addEventListener('mousemove', (e) => {
202 if (!dragging) return;
203 const el = lineOf(document.elementFromPoint(e.clientX, e.clientY));
204 if (!el || el === dragCurrent) return;
205 if (columnOf(el) !== columnOf(dragAnchor)) return;
206 dragCurrent = el;
207 applyHl(dragAnchor, dragCurrent, 'line-quote-hl');
208 stretchBtn(dragAnchor, dragCurrent);
209 });
210
211 const insertIntoTextarea = (ta, selected) => {
212 const first = selected[0];
213 const last = selected[selected.length - 1];
214 const fNum = lineNumOf(first);
215 const firstAnchor = anchorOf(first);
216
217 if (!fNum || !firstAnchor) return;
218
219 const file = fileOf(first);
220 const lNum = lineNumOf(last);
221 const lastAnchor = anchorOf(last);
222
223 const label = selected.length === 1
224 ? (file ? `${file}:${fNum}` : `L${fNum}`)
225 : (file ? `${file}:${fNum}-${lNum}` : `L${fNum}-${lNum}`);
226
227 const fragment = selected.length === 1
228 ? firstAnchor
229 : `${firstAnchor}~${lastAnchor}`;
230
231 const linkBase = document.getElementById('round-link-base')?.value
232 || (window.location.pathname + window.location.search);
233 const md = `[\`${label}\`](${linkBase}#${fragment})`;
234
235 const { selectionStart: s, selectionEnd: end, value } = ta;
236 const before = value.slice(0, s);
237 const after = value.slice(end);
238 let pre = '', suf = '';
239 if (s === end && before.length > 0) {
240 const cur = before.slice(before.lastIndexOf('\n') + 1);
241 if (cur.length > 0) {
242 const nextNl = after.indexOf('\n');
243 const rest = nextNl === -1 ? after : after.slice(0, nextNl);
244 if (rest.trim().length === 0) { pre = '\n'; }
245 else { pre = before.endsWith(' ') ? '' : ' '; suf = after.startsWith(' ') ? '' : ' '; }
246 }
247 }
248 ta.value = before + pre + md + suf + after;
249 ta.selectionStart = ta.selectionEnd = s + pre.length + md.length + suf.length;
250 ta.focus();
251 ta.dispatchEvent(new Event('input', { bubbles: true }));
252 };
253
254 document.addEventListener('mouseup', () => {
255 if (!dragging) return;
256 dragging = false;
257 document.body.style.userSelect = '';
258
259 const selected = rangeBetween(dragAnchor, dragCurrent);
260 if (selected.length > 0) {
261 openCommentForm().then(ta => {
262 if (ta) insertIntoTextarea(ta, selected);
263 });
264 }
265
266 clearHl('line-quote-hl');
267 dragLines = null;
268 dragAnchor = dragCurrent = hoverTarget = null;
269 hideBtn();
270 });
271
272 btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); });
273
274 const cancelDrag = () => {
275 if (!dragging) return;
276 dragging = false;
277 document.body.style.userSelect = '';
278 clearHl('line-quote-hl');
279 dragLines = null;
280 dragAnchor = dragCurrent = hoverTarget = null;
281 hideBtn();
282 };
283 window.addEventListener('blur', cancelDrag);
284 })();
285</script>
286{{ end }}