A vibe coded tangled fork which supports pijul.

appview/pages: multiline PR comment links

Signed-off-by: Lewis <lewis@tangled.org>

authored by

Lewis and committed by tangled.org bc2f8b16 77981e7b

+203 -70
+191 -70
appview/pages/templates/fragments/line-quote-button.html
··· 3 3 id="line-quote-btn" 4 4 type="button" 5 5 aria-label="Quote line in comment" 6 - class="hidden fixed z-50 p-0.5 rounded bg-gray-100 dark:bg-gray-700 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-200 dark:hover:bg-gray-600 cursor-pointer shadow-sm transition-opacity opacity-0" 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 7 style="pointer-events: none;" 8 8 > 9 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> 10 13 </button> 11 14 <script> 12 15 (() => { 13 16 const btn = document.getElementById('line-quote-btn'); 14 17 if (!btn) return; 18 + const btnEnd = document.getElementById('line-quote-btn-end'); 15 19 16 - let currentAnchor = null; 17 - let currentFileName = null; 18 - 19 - const findTextarea = () => 20 + const textarea = () => 20 21 document.getElementById('pull-comment-textarea') 21 22 || document.getElementById('comment-textarea'); 22 23 23 - const findLineEl = (el) => 24 - el?.closest?.('.line') 25 - || el?.closest?.('span[id*="-O"]') 24 + const lineOf = (el) => 25 + el?.closest?.('span[id*="-O"]') 26 26 || el?.closest?.('span[id*="-N"]'); 27 27 28 - const getAnchor = (lineEl) => { 29 - const link = lineEl.querySelector('a[href^="#"]'); 30 - return link ? link.getAttribute('href').slice(1) : lineEl.id || null; 28 + const anchorOf = (el) => { 29 + const link = el.querySelector('a[href^="#"]'); 30 + return link ? link.getAttribute('href').slice(1) : el.id || null; 31 31 }; 32 32 33 - const getFileName = (lineEl) => { 34 - const details = lineEl.closest('details[id^="file-"]'); 35 - if (details) return details.id.replace(/^file-/, ''); 36 - const bc = document.getElementById('breadcrumbs'); 37 - if (!bc) return null; 38 - const els = bc.querySelectorAll('.text-bold'); 39 - return els.length > 0 ? els[els.length - 1].textContent.trim() : null; 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' })); 40 96 }; 41 97 42 - const show = (lineEl) => { 43 - if (!findTextarea()) return; 44 - const anchor = getAnchor(lineEl); 45 - if (!anchor) return; 98 + if (document.readyState === 'loading') { 99 + document.addEventListener('DOMContentLoaded', highlightFromHash); 100 + } else { 101 + highlightFromHash(); 102 + } 103 + window.addEventListener('hashchange', highlightFromHash); 46 104 47 - currentAnchor = anchor; 48 - currentFileName = getFileName(lineEl); 105 + let dragging = false; 106 + let dragAnchor = null; 107 + let dragCurrent = null; 108 + let hoverTarget = null; 49 109 110 + const showBtn = (lineEl) => { 111 + if (!textarea() || !anchorOf(lineEl)) return; 50 112 const rect = lineEl.getBoundingClientRect(); 51 113 Object.assign(btn.style, { 52 114 top: `${rect.top + rect.height / 2 - btn.offsetHeight / 2}px`, 53 115 left: `${rect.left + 4}px`, 116 + height: '', 54 117 opacity: '1', 55 118 pointerEvents: 'auto', 56 119 }); 57 120 btn.classList.remove('hidden'); 58 121 }; 59 122 60 - const hide = () => { 61 - Object.assign(btn.style, { opacity: '0', pointerEvents: 'none' }); 123 + const hideBtn = () => { 124 + if (dragging) return; 125 + Object.assign(btn.style, { opacity: '0', pointerEvents: 'none', height: '' }); 126 + btnEnd.classList.add('hidden'); 62 127 setTimeout(() => { if (btn.style.opacity === '0') btn.classList.add('hidden'); }, 150); 63 128 }; 64 129 65 - let hoverTarget = null; 130 + const stretchBtn = (a, b) => { 131 + const aRect = a.getBoundingClientRect(); 132 + const bRect = b.getBoundingClientRect(); 133 + const top = Math.min(aRect.top, bRect.top); 134 + const bottom = Math.max(aRect.bottom, bRect.bottom); 135 + const multiLine = a !== b; 136 + Object.assign(btn.style, { 137 + top: `${top}px`, 138 + left: `${aRect.left + 4}px`, 139 + height: `${bottom - top}px`, 140 + }); 141 + if (multiLine) { btnEnd.classList.remove('hidden'); } 142 + else { btnEnd.classList.add('hidden'); } 143 + }; 66 144 67 145 document.addEventListener('mouseover', (e) => { 68 - const lineEl = findLineEl(e.target); 69 - if (lineEl && lineEl !== hoverTarget) { 70 - hoverTarget = lineEl; 71 - show(lineEl); 72 - } 146 + if (dragging || e.target === btn || btn.contains(e.target)) return; 147 + const el = lineOf(e.target); 148 + if (el && el !== hoverTarget) { hoverTarget = el; showBtn(el); } 73 149 }); 74 150 75 151 document.addEventListener('mouseout', (e) => { 76 - const lineEl = findLineEl(e.target); 77 - if (!lineEl) return; 78 - if (findLineEl(e.relatedTarget) === lineEl) return; 79 - if (e.relatedTarget === btn || btn.contains(e.relatedTarget)) return; 152 + if (dragging) return; 153 + const el = lineOf(e.target); 154 + if (!el || lineOf(e.relatedTarget) === el || e.relatedTarget === btn || btn.contains(e.relatedTarget)) return; 80 155 hoverTarget = null; 81 - hide(); 156 + hideBtn(); 82 157 }); 83 158 84 159 btn.addEventListener('mouseleave', (e) => { 85 - if (!findLineEl(e.relatedTarget)) { 86 - hoverTarget = null; 87 - hide(); 88 - } 160 + if (!dragging && !lineOf(e.relatedTarget)) { hoverTarget = null; hideBtn(); } 89 161 }); 90 162 91 - btn.addEventListener('click', (e) => { 163 + btn.addEventListener('mousedown', (e) => { 164 + if (e.button !== 0 || !hoverTarget) return; 92 165 e.preventDefault(); 93 - e.stopPropagation(); 94 - const textarea = findTextarea(); 95 - if (!textarea || !currentAnchor) return; 166 + dragging = true; 167 + dragAnchor = dragCurrent = hoverTarget; 168 + const col = columnOf(hoverTarget); 169 + dragLines = col ? linesInColumn(col) : null; 170 + applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 171 + btn.style.pointerEvents = 'none'; 172 + document.body.style.userSelect = 'none'; 173 + }); 96 174 97 - const lineNum = currentAnchor.match(/[ON]?(\d+)(?:-[ON]?\d+)?$/)?.[1]; 98 - if (!lineNum) return; 175 + document.addEventListener('mousemove', (e) => { 176 + if (!dragging) return; 177 + const el = lineOf(document.elementFromPoint(e.clientX, e.clientY)); 178 + if (!el || el === dragCurrent) return; 179 + if (columnOf(el) !== columnOf(dragAnchor)) return; 180 + dragCurrent = el; 181 + applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 182 + stretchBtn(dragAnchor, dragCurrent); 183 + }); 99 184 100 - const label = currentFileName ? `${currentFileName}:${lineNum}` : `L${lineNum}`; 101 - const md = `[\`${label}\`](${window.location.pathname}#${currentAnchor})`; 185 + document.addEventListener('mouseup', () => { 186 + if (!dragging) return; 187 + dragging = false; 188 + document.body.style.userSelect = ''; 102 189 103 - const { selectionStart: start, selectionEnd: end, value } = textarea; 104 - const before = value.slice(0, start); 105 - const after = value.slice(end); 190 + const selected = rangeBetween(dragAnchor, dragCurrent); 191 + const ta = textarea(); 192 + if (ta && selected.length > 0) { 193 + const first = selected[0]; 194 + const last = selected[selected.length - 1]; 195 + const fNum = lineNumOf(first); 196 + const firstAnchor = anchorOf(first); 106 197 107 - let prefix = ''; 108 - let suffix = ''; 109 - if (start === end && before.length > 0) { 110 - const currentLine = before.slice(before.lastIndexOf('\n') + 1); 111 - if (currentLine.length > 0) { 112 - const nextNl = after.indexOf('\n'); 113 - const restOfLine = nextNl === -1 ? after : after.slice(0, nextNl); 114 - if (restOfLine.trim().length === 0) { 115 - prefix = '\n'; 116 - } else { 117 - prefix = before.endsWith(' ') ? '' : ' '; 118 - suffix = after.startsWith(' ') ? '' : ' '; 198 + if (fNum && firstAnchor) { 199 + const file = fileOf(first); 200 + const lNum = lineNumOf(last); 201 + const lastAnchor = anchorOf(last); 202 + 203 + const label = selected.length === 1 204 + ? (file ? `${file}:${fNum}` : `L${fNum}`) 205 + : (file ? `${file}:${fNum}-${lNum}` : `L${fNum}-${lNum}`); 206 + 207 + const fragment = selected.length === 1 208 + ? firstAnchor 209 + : `${firstAnchor}~${lastAnchor}`; 210 + 211 + const md = `[\`${label}\`](${window.location.pathname}${window.location.search}#${fragment})`; 212 + 213 + const { selectionStart: s, selectionEnd: end, value } = ta; 214 + const before = value.slice(0, s); 215 + const after = value.slice(end); 216 + let pre = '', suf = ''; 217 + if (s === end && before.length > 0) { 218 + const cur = before.slice(before.lastIndexOf('\n') + 1); 219 + if (cur.length > 0) { 220 + const nextNl = after.indexOf('\n'); 221 + const rest = nextNl === -1 ? after : after.slice(0, nextNl); 222 + if (rest.trim().length === 0) { pre = '\n'; } 223 + else { pre = before.endsWith(' ') ? '' : ' '; suf = after.startsWith(' ') ? '' : ' '; } 224 + } 119 225 } 226 + ta.value = before + pre + md + suf + after; 227 + ta.selectionStart = ta.selectionEnd = s + pre.length + md.length + suf.length; 228 + ta.focus(); 229 + ta.dispatchEvent(new Event('input', { bubbles: true })); 120 230 } 121 231 } 122 232 123 - textarea.value = before + prefix + md + suffix + after; 124 - const pos = start + prefix.length + md.length + suffix.length; 125 - textarea.selectionStart = textarea.selectionEnd = pos; 126 - textarea.focus(); 127 - textarea.dispatchEvent(new Event('input', { bubbles: true })); 128 - textarea.dispatchEvent(new Event('keyup', { bubbles: true })); 233 + clearHl('line-quote-hl'); 234 + dragLines = null; 235 + dragAnchor = dragCurrent = hoverTarget = null; 236 + hideBtn(); 129 237 }); 238 + 239 + btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }); 240 + 241 + const cancelDrag = () => { 242 + if (!dragging) return; 243 + dragging = false; 244 + document.body.style.userSelect = ''; 245 + clearHl('line-quote-hl'); 246 + dragLines = null; 247 + dragAnchor = dragCurrent = hoverTarget = null; 248 + hideBtn(); 249 + }; 250 + window.addEventListener('blur', cancelDrag); 130 251 })(); 131 252 </script> 132 253 {{ end }}
+12
input.css
··· 373 373 @apply bg-amber-400/30 dark:bg-amber-500/20; 374 374 } 375 375 376 + .line-quote-hl, .line-range-hl { 377 + @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; 378 + } 379 + 380 + :is(.line-quote-hl, .line-range-hl) > .min-w-\[3\.5rem\] { 381 + @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; 382 + } 383 + 384 + :is(.line-quote-hl, .line-range-hl) > .min-w-\[3\.5rem\] a { 385 + @apply !text-black dark:!text-white; 386 + } 387 + 376 388 /* LineNumbersTable */ 377 389 .chroma .lnt { 378 390 white-space: pre;