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