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.8 kB 284 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('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 }}