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