This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / pages / templates / fragments / line-quote-button.html
9.7 kB 275 lines
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 }}