Monorepo for Tangled tangled.org

appview/pages: multiline PR comment links #1170

merged opened by oyster.cafe targeting master from lt/appview-pages-multiline-pr-comment-links
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mh6hcv6b5u22
+196 -74
Diff #0
+184 -74
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 + }; 32 + 33 + const fileOf = (el) => { 34 + const d = el.closest('details[id^="file-"]'); 35 + return d ? d.id.replace(/^file-/, '') : null; 31 36 }; 32 37 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; 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); 40 56 }; 41 57 42 - const show = (lineEl) => { 43 - if (!findTextarea()) return; 44 - const anchor = getAnchor(lineEl); 45 - if (!anchor) return; 58 + const clearHl = (cls) => 59 + document.querySelectorAll(`.${cls}`).forEach(el => el.classList.remove(cls)); 46 60 47 - currentAnchor = anchor; 48 - currentFileName = getFileName(lineEl); 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 + }; 49 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 + if (!startEl) return; 75 + 76 + const endEl = parts.length === 2 ? document.getElementById(parts[1]) : startEl; 77 + if (!endEl) return; 78 + 79 + const details = startEl.closest('details'); 80 + if (details) details.open = true; 81 + 82 + applyHl(startEl, endEl, 'line-range-hl'); 83 + requestAnimationFrame(() => 84 + startEl.scrollIntoView({ behavior: 'smooth', block: 'center' })); 85 + }; 86 + 87 + if (document.readyState === 'loading') { 88 + document.addEventListener('DOMContentLoaded', highlightFromHash); 89 + } else { 90 + highlightFromHash(); 91 + } 92 + window.addEventListener('hashchange', highlightFromHash); 93 + 94 + let dragging = false; 95 + let dragAnchor = null; 96 + let dragCurrent = null; 97 + let hoverTarget = null; 98 + 99 + const showBtn = (lineEl) => { 100 + if (!textarea() || !anchorOf(lineEl)) return; 50 101 const rect = lineEl.getBoundingClientRect(); 51 102 Object.assign(btn.style, { 52 103 top: `${rect.top + rect.height / 2 - btn.offsetHeight / 2}px`, 53 104 left: `${rect.left + 4}px`, 105 + height: '', 54 106 opacity: '1', 55 107 pointerEvents: 'auto', 56 108 }); 57 109 btn.classList.remove('hidden'); 58 110 }; 59 111 60 - const hide = () => { 61 - Object.assign(btn.style, { opacity: '0', pointerEvents: 'none' }); 112 + const hideBtn = () => { 113 + if (dragging) return; 114 + Object.assign(btn.style, { opacity: '0', pointerEvents: 'none', height: '' }); 115 + btnEnd.classList.add('hidden'); 62 116 setTimeout(() => { if (btn.style.opacity === '0') btn.classList.add('hidden'); }, 150); 63 117 }; 64 118 65 - let hoverTarget = null; 119 + const stretchBtn = (a, b) => { 120 + const aRect = a.getBoundingClientRect(); 121 + const bRect = b.getBoundingClientRect(); 122 + const top = Math.min(aRect.top, bRect.top); 123 + const bottom = Math.max(aRect.bottom, bRect.bottom); 124 + const multiLine = a !== b; 125 + Object.assign(btn.style, { 126 + top: `${top}px`, 127 + left: `${aRect.left + 4}px`, 128 + height: `${bottom - top}px`, 129 + }); 130 + if (multiLine) { btnEnd.classList.remove('hidden'); } 131 + else { btnEnd.classList.add('hidden'); } 132 + }; 66 133 67 134 document.addEventListener('mouseover', (e) => { 68 - const lineEl = findLineEl(e.target); 69 - if (lineEl && lineEl !== hoverTarget) { 70 - hoverTarget = lineEl; 71 - show(lineEl); 72 - } 135 + if (dragging || e.target === btn || btn.contains(e.target)) return; 136 + const el = lineOf(e.target); 137 + if (el && el !== hoverTarget) { hoverTarget = el; showBtn(el); } 73 138 }); 74 139 75 140 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; 141 + if (dragging) return; 142 + const el = lineOf(e.target); 143 + if (!el || lineOf(e.relatedTarget) === el || e.relatedTarget === btn || btn.contains(e.relatedTarget)) return; 80 144 hoverTarget = null; 81 - hide(); 145 + hideBtn(); 82 146 }); 83 147 84 148 btn.addEventListener('mouseleave', (e) => { 85 - if (!findLineEl(e.relatedTarget)) { 86 - hoverTarget = null; 87 - hide(); 88 - } 149 + if (!dragging && !lineOf(e.relatedTarget)) { hoverTarget = null; hideBtn(); } 89 150 }); 90 151 91 - btn.addEventListener('click', (e) => { 152 + btn.addEventListener('mousedown', (e) => { 153 + if (e.button !== 0 || !hoverTarget) return; 92 154 e.preventDefault(); 93 - e.stopPropagation(); 94 - const textarea = findTextarea(); 95 - if (!textarea || !currentAnchor) return; 96 - 97 - const lineNum = currentAnchor.match(/[ON]?(\d+)(?:-[ON]?\d+)?$/)?.[1]; 98 - if (!lineNum) return; 99 - 100 - const label = currentFileName ? `${currentFileName}:${lineNum}` : `L${lineNum}`; 101 - const md = `[\`${label}\`](${window.location.pathname}#${currentAnchor})`; 102 - 103 - const { selectionStart: start, selectionEnd: end, value } = textarea; 104 - const before = value.slice(0, start); 105 - const after = value.slice(end); 106 - 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(' ') ? '' : ' '; 155 + dragging = true; 156 + dragAnchor = dragCurrent = hoverTarget; 157 + const col = columnOf(hoverTarget); 158 + dragLines = col ? linesInColumn(col) : null; 159 + applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 160 + btn.style.pointerEvents = 'none'; 161 + document.body.style.userSelect = 'none'; 162 + }); 163 + 164 + document.addEventListener('mousemove', (e) => { 165 + if (!dragging) return; 166 + const el = lineOf(document.elementFromPoint(e.clientX, e.clientY)); 167 + if (!el || el === dragCurrent) return; 168 + if (columnOf(el) !== columnOf(dragAnchor)) return; 169 + dragCurrent = el; 170 + applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 171 + stretchBtn(dragAnchor, dragCurrent); 172 + }); 173 + 174 + document.addEventListener('mouseup', () => { 175 + if (!dragging) return; 176 + dragging = false; 177 + document.body.style.userSelect = ''; 178 + 179 + const selected = rangeBetween(dragAnchor, dragCurrent); 180 + const ta = textarea(); 181 + if (ta && selected.length > 0) { 182 + const first = selected[0]; 183 + const last = selected[selected.length - 1]; 184 + const fNum = lineNumOf(first); 185 + const firstAnchor = anchorOf(first); 186 + 187 + if (fNum && firstAnchor) { 188 + const file = fileOf(first); 189 + const lNum = lineNumOf(last); 190 + const lastAnchor = anchorOf(last); 191 + 192 + const label = selected.length === 1 193 + ? (file ? `${file}:${fNum}` : `L${fNum}`) 194 + : (file ? `${file}:${fNum}-${lNum}` : `L${fNum}-${lNum}`); 195 + 196 + const fragment = selected.length === 1 197 + ? firstAnchor 198 + : `${firstAnchor}~${lastAnchor}`; 199 + 200 + const md = `[\`${label}\`](${window.location.pathname}#${fragment})`; 201 + 202 + const { selectionStart: s, selectionEnd: end, value } = ta; 203 + const before = value.slice(0, s); 204 + const after = value.slice(end); 205 + let pre = '', suf = ''; 206 + if (s === end && before.length > 0) { 207 + const cur = before.slice(before.lastIndexOf('\n') + 1); 208 + if (cur.length > 0) { 209 + const nextNl = after.indexOf('\n'); 210 + const rest = nextNl === -1 ? after : after.slice(0, nextNl); 211 + if (rest.trim().length === 0) { pre = '\n'; } 212 + else { pre = before.endsWith(' ') ? '' : ' '; suf = after.startsWith(' ') ? '' : ' '; } 213 + } 119 214 } 215 + ta.value = before + pre + md + suf + after; 216 + ta.selectionStart = ta.selectionEnd = s + pre.length + md.length + suf.length; 217 + ta.focus(); 218 + ta.dispatchEvent(new Event('input', { bubbles: true })); 120 219 } 121 220 } 122 221 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 })); 222 + clearHl('line-quote-hl'); 223 + dragLines = null; 224 + dragAnchor = dragCurrent = hoverTarget = null; 225 + hideBtn(); 129 226 }); 227 + 228 + btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }); 229 + 230 + const cancelDrag = () => { 231 + if (!dragging) return; 232 + dragging = false; 233 + document.body.style.userSelect = ''; 234 + clearHl('line-quote-hl'); 235 + dragLines = null; 236 + dragAnchor = dragCurrent = hoverTarget = null; 237 + hideBtn(); 238 + }; 239 + window.addEventListener('blur', cancelDrag); 130 240 })(); 131 241 </script> 132 242 {{ 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;

History

2 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
appview/pages: multiline PR comment links
3/3 success
expand
expand 0 comments
pull request successfully merged
oyster.cafe submitted #0
1 commit
expand
appview/pages: multiline PR comment links
3/3 success
expand
expand 0 comments