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