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