forgejo/web_src/js/features/repo-code.js
Solomon Victorino 3ee5bc262f fix(ui): handle out-of-bounds end line in code selection (#4788)
- fallback to the last line, preventing TypeError
- add E2E test

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4788
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Solomon Victorino <git@solomonvictorino.com>
Co-committed-by: Solomon Victorino <git@solomonvictorino.com>
2024-08-05 04:45:07 +00:00

196 lines
6.1 KiB
JavaScript

import $ from 'jquery';
import {svg} from '../svg.js';
import {invertFileFolding} from './file-fold.js';
import {createTippy} from '../modules/tippy.js';
import {clippie} from 'clippie';
import {toAbsoluteUrl} from '../utils.js';
export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/;
export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/;
function changeHash(hash) {
if (window.history.pushState) {
window.history.pushState(null, null, hash);
} else {
window.location.hash = hash;
}
}
function isBlame() {
return Boolean(document.querySelector('div.blame'));
}
function getLineEls() {
return document.querySelectorAll(`.code-view td.lines-code${isBlame() ? '.blame-code' : ''}`);
}
function selectRange($linesEls, $selectionEndEl, $selectionStartEls) {
for (const el of $linesEls) {
el.closest('tr').classList.remove('active');
}
// add hashchange to permalink
const refInNewIssue = document.querySelector('a.ref-in-new-issue');
const copyPermalink = document.querySelector('a.copy-line-permalink');
const viewGitBlame = document.querySelector('a.view_git_blame');
const updateIssueHref = function (anchor) {
if (!refInNewIssue) return;
const urlIssueNew = refInNewIssue.getAttribute('data-url-issue-new');
const urlParamBodyLink = refInNewIssue.getAttribute('data-url-param-body-link');
const issueContent = `${toAbsoluteUrl(urlParamBodyLink)}#${anchor}`; // the default content for issue body
refInNewIssue.setAttribute('href', `${urlIssueNew}?body=${encodeURIComponent(issueContent)}`);
};
const updateViewGitBlameFragment = function (anchor) {
if (!viewGitBlame) return;
let href = viewGitBlame.getAttribute('href');
href = `${href.replace(/#L\d+$|#L\d+-L\d+$/, '')}`;
if (anchor.length !== 0) {
href = `${href}#${anchor}`;
}
viewGitBlame.setAttribute('href', href);
};
const updateCopyPermalinkUrl = function (anchor) {
if (!copyPermalink) return;
let link = copyPermalink.getAttribute('data-url');
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
copyPermalink.setAttribute('data-url', link);
};
if ($selectionStartEls) {
let a = parseInt($selectionEndEl[0].getAttribute('rel').slice(1));
let b = parseInt($selectionStartEls[0].getAttribute('rel').slice(1));
let c;
if (a !== b) {
if (a > b) {
c = a;
a = b;
b = c;
}
const classes = [];
for (let i = a; i <= b; i++) {
classes.push(`[rel=L${i}]`);
}
$linesEls.filter(classes.join(',')).each(function () {
this.closest('tr').classList.add('active');
});
changeHash(`#L${a}-L${b}`);
updateIssueHref(`L${a}-L${b}`);
updateViewGitBlameFragment(`L${a}-L${b}`);
updateCopyPermalinkUrl(`L${a}-L${b}`);
return;
}
}
$selectionEndEl[0].closest('tr').classList.add('active');
changeHash(`#${$selectionEndEl[0].getAttribute('rel')}`);
updateIssueHref($selectionEndEl[0].getAttribute('rel'));
updateViewGitBlameFragment($selectionEndEl[0].getAttribute('rel'));
updateCopyPermalinkUrl($selectionEndEl[0].getAttribute('rel'));
}
function showLineButton() {
const menu = document.querySelector('.code-line-menu');
if (!menu) return;
// remove all other line buttons
for (const el of document.querySelectorAll('.code-line-button')) {
el.remove();
}
// find active row and add button
const tr = document.querySelector('.code-view tr.active');
const td = tr.querySelector('td.lines-num');
const btn = document.createElement('button');
btn.classList.add('code-line-button', 'ui', 'basic', 'button');
btn.innerHTML = svg('octicon-kebab-horizontal');
td.prepend(btn);
// put a copy of the menu back into DOM for the next click
btn.closest('.code-view').append(menu.cloneNode(true));
createTippy(btn, {
trigger: 'click',
hideOnClick: true,
content: menu,
placement: 'right-start',
interactive: true,
onShow: (tippy) => {
tippy.popper.addEventListener('click', () => {
tippy.hide();
}, {once: true});
},
});
}
export function initRepoCodeView() {
if ($('.code-view .lines-num').length > 0) {
$(document).on('click', '.lines-num span', function (e) {
const linesEls = getLineEls();
const selectedEls = Array.from(linesEls).filter((el) => {
return el.matches(`[rel=${this.getAttribute('id')}]`);
});
let from;
if (e.shiftKey) {
from = Array.from(linesEls).filter((el) => {
return el.closest('tr').classList.contains('active');
});
}
selectRange($(linesEls), $(selectedEls), from ? $(from) : null);
if (window.getSelection) {
window.getSelection().removeAllRanges();
} else {
document.selection.empty();
}
showLineButton();
});
$(window).on('hashchange', () => {
let m = window.location.hash.match(rangeAnchorRegex);
const $linesEls = $(getLineEls());
let $first;
if (m) {
$first = $linesEls.filter(`[rel=${m[1]}]`);
if ($first.length) {
const $last = $linesEls.filter(`[rel=${m[2]}]`);
selectRange($linesEls, $first, $last.length ? $last : $linesEls.last());
// show code view menu marker (don't show in blame page)
if (!isBlame()) {
showLineButton();
}
$('html, body').scrollTop($first.offset().top - 200);
return;
}
}
m = window.location.hash.match(singleAnchorRegex);
if (m) {
$first = $linesEls.filter(`[rel=L${m[2]}]`);
if ($first.length) {
selectRange($linesEls, $first);
// show code view menu marker (don't show in blame page)
if (!isBlame()) {
showLineButton();
}
$('html, body').scrollTop($first.offset().top - 200);
}
}
}).trigger('hashchange');
}
$(document).on('click', '.fold-file', ({currentTarget}) => {
invertFileFolding(currentTarget.closest('.file-content'), currentTarget);
});
$(document).on('click', '.copy-line-permalink', async ({currentTarget}) => {
await clippie(toAbsoluteUrl(currentTarget.getAttribute('data-url')));
});
}