gitea源码

repo-issue.ts 24KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. import {html, htmlEscape} from '../utils/html.ts';
  2. import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
  3. import {
  4. addDelegatedEventListener,
  5. createElementFromHTML,
  6. hideElem,
  7. queryElems,
  8. showElem,
  9. toggleElem,
  10. type DOMEvent,
  11. } from '../utils/dom.ts';
  12. import {setFileFolding} from './file-fold.ts';
  13. import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
  14. import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts';
  15. import {GET, POST} from '../modules/fetch.ts';
  16. import {showErrorToast} from '../modules/toast.ts';
  17. import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
  18. import {fomanticQuery} from '../modules/fomantic/base.ts';
  19. import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
  20. import {registerGlobalInitFunc} from '../modules/observer.ts';
  21. const {appSubUrl} = window.config;
  22. export function initRepoIssueSidebarDependency() {
  23. const elDropdown = document.querySelector('#new-dependency-drop-list');
  24. if (!elDropdown) return;
  25. const issuePageInfo = parseIssuePageInfo();
  26. const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
  27. let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
  28. if (crossRepoSearch === 'true') {
  29. issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
  30. }
  31. fomanticQuery(elDropdown).dropdown({
  32. fullTextSearch: true,
  33. apiSettings: {
  34. cache: false,
  35. rawResponse: true,
  36. url: issueSearchUrl,
  37. onResponse(response: any) {
  38. const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
  39. const currIssueId = elDropdown.getAttribute('data-issue-id');
  40. // Parse the response from the api to work with our dropdown
  41. for (const issue of response) {
  42. // Don't list current issue in the dependency list.
  43. if (String(issue.id) === currIssueId) continue;
  44. filteredResponse.results.push({
  45. value: issue.id,
  46. name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
  47. });
  48. }
  49. return filteredResponse;
  50. },
  51. },
  52. });
  53. }
  54. function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
  55. const url = new URL(window.location.href);
  56. const showArchivedLabels = url.searchParams.get('archived_labels') === 'true';
  57. const queryLabels = url.searchParams.get('labels') || '';
  58. const selectedLabelIds = new Set<string>();
  59. for (const id of queryLabels ? queryLabels.split(',') : []) {
  60. selectedLabelIds.add(`${Math.abs(parseInt(id))}`); // "labels" contains negative ids, which are excluded
  61. }
  62. const excludeLabel = (e: MouseEvent | KeyboardEvent, item: Element) => {
  63. e.preventDefault();
  64. e.stopPropagation();
  65. const labelId = item.getAttribute('data-label-id');
  66. let labelIds: string[] = queryLabels ? queryLabels.split(',') : [];
  67. labelIds = labelIds.filter((id) => Math.abs(parseInt(id)) !== Math.abs(parseInt(labelId)));
  68. labelIds.push(`-${labelId}`);
  69. url.searchParams.set('labels', labelIds.join(','));
  70. window.location.assign(url);
  71. };
  72. // alt(or option) + click to exclude label
  73. queryElems(elDropdown, '.label-filter-query-item', (el) => {
  74. el.addEventListener('click', (e: MouseEvent) => {
  75. if (e.altKey) excludeLabel(e, el);
  76. });
  77. });
  78. // alt(or option) + enter to exclude selected label
  79. elDropdown.addEventListener('keydown', (e: KeyboardEvent) => {
  80. if (e.altKey && e.key === 'Enter') {
  81. const selectedItem = elDropdown.querySelector('.label-filter-query-item.selected');
  82. if (selectedItem) excludeLabel(e, selectedItem);
  83. }
  84. });
  85. // no "labels" query parameter means "all issues"
  86. elDropdown.querySelector('.label-filter-query-default').classList.toggle('selected', queryLabels === '');
  87. // "labels=0" query parameter means "issues without label"
  88. elDropdown.querySelector('.label-filter-query-not-set').classList.toggle('selected', queryLabels === '0');
  89. // prepare to process "archived" labels
  90. const elShowArchivedLabel = elDropdown.querySelector('.label-filter-archived-toggle');
  91. if (!elShowArchivedLabel) return;
  92. const elShowArchivedInput = elShowArchivedLabel.querySelector<HTMLInputElement>('input');
  93. elShowArchivedInput.checked = showArchivedLabels;
  94. const archivedLabels = elDropdown.querySelectorAll('.item[data-is-archived]');
  95. // if no archived labels, hide the toggle and return
  96. if (!archivedLabels.length) {
  97. hideElem(elShowArchivedLabel);
  98. return;
  99. }
  100. // show the archived labels if the toggle is checked or the label is selected
  101. for (const label of archivedLabels) {
  102. toggleElem(label, showArchivedLabels || selectedLabelIds.has(label.getAttribute('data-label-id')));
  103. }
  104. // update the url when the toggle is changed and reload
  105. elShowArchivedInput.addEventListener('input', () => {
  106. if (elShowArchivedInput.checked) {
  107. url.searchParams.set('archived_labels', 'true');
  108. } else {
  109. url.searchParams.delete('archived_labels');
  110. }
  111. window.location.assign(url);
  112. });
  113. }
  114. export function initRepoIssueFilterItemLabel() {
  115. // the "label-filter" is used in 2 templates: projects/view, issue/filter_list (issue list page including the milestone page)
  116. queryElems(document, '.ui.dropdown.label-filter', initRepoIssueLabelFilter);
  117. }
  118. export function initRepoIssueCommentDelete() {
  119. // Delete comment
  120. document.addEventListener('click', async (e: DOMEvent<MouseEvent>) => {
  121. if (!e.target.matches('.delete-comment')) return;
  122. e.preventDefault();
  123. const deleteButton = e.target;
  124. if (window.confirm(deleteButton.getAttribute('data-locale'))) {
  125. try {
  126. const response = await POST(deleteButton.getAttribute('data-url'));
  127. if (!response.ok) throw new Error('Failed to delete comment');
  128. const conversationHolder = deleteButton.closest('.conversation-holder');
  129. const parentTimelineItem = deleteButton.closest('.timeline-item');
  130. const parentTimelineGroup = deleteButton.closest('.timeline-item-group');
  131. // Check if this was a pending comment.
  132. if (conversationHolder?.querySelector('.pending-label')) {
  133. const counter = document.querySelector('#review-box .review-comments-counter');
  134. let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0;
  135. num = Math.max(num, 0);
  136. counter.setAttribute('data-pending-comment-number', String(num));
  137. counter.textContent = String(num);
  138. }
  139. document.querySelector(`#${deleteButton.getAttribute('data-comment-id')}`)?.remove();
  140. if (conversationHolder && !conversationHolder.querySelector('.comment')) {
  141. const path = conversationHolder.getAttribute('data-path');
  142. const side = conversationHolder.getAttribute('data-side');
  143. const idx = conversationHolder.getAttribute('data-idx');
  144. const lineType = conversationHolder.closest('tr')?.getAttribute('data-line-type');
  145. // the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page
  146. // on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment"
  147. if (lineType) {
  148. if (lineType === 'same') {
  149. document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible');
  150. } else {
  151. document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible');
  152. }
  153. }
  154. conversationHolder.remove();
  155. }
  156. // Check if there is no review content, move the time avatar upward to avoid overlapping the content below.
  157. if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) {
  158. const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar');
  159. timelineAvatar?.classList.remove('timeline-avatar-offset');
  160. }
  161. } catch (error) {
  162. console.error(error);
  163. }
  164. }
  165. });
  166. }
  167. export function initRepoIssueCodeCommentCancel() {
  168. // Cancel inline code comment
  169. document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
  170. if (!e.target.matches('.cancel-code-comment')) return;
  171. const form = e.target.closest('form');
  172. if (form?.classList.contains('comment-form')) {
  173. hideElem(form);
  174. showElem(form.closest('.comment-code-cloud')?.querySelectorAll('button.comment-form-reply'));
  175. } else {
  176. form.closest('.comment-code-cloud')?.remove();
  177. }
  178. });
  179. }
  180. export function initRepoPullRequestAllowMaintainerEdit() {
  181. const wrapper = document.querySelector('#allow-edits-from-maintainers');
  182. if (!wrapper) return;
  183. const checkbox = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]');
  184. checkbox.addEventListener('input', async () => {
  185. const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
  186. wrapper.classList.add('is-loading');
  187. try {
  188. const resp = await POST(url, {data: new URLSearchParams({
  189. allow_maintainer_edit: String(checkbox.checked),
  190. })});
  191. if (!resp.ok) {
  192. throw new Error('Failed to update maintainer edit permission');
  193. }
  194. const data = await resp.json();
  195. checkbox.checked = data.allow_maintainer_edit;
  196. } catch (error) {
  197. checkbox.checked = !checkbox.checked;
  198. console.error(error);
  199. showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error'));
  200. } finally {
  201. wrapper.classList.remove('is-loading');
  202. }
  203. });
  204. }
  205. export function initRepoIssueComments() {
  206. if (!document.querySelector('.repository.view.issue .timeline')) return;
  207. document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
  208. const urlTarget = document.querySelector(':target');
  209. if (!urlTarget) return;
  210. const urlTargetId = urlTarget.id;
  211. if (!urlTargetId) return;
  212. if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return;
  213. if (!e.target.closest(`#${urlTargetId}`)) {
  214. // if the user clicks outside the comment, remove the hash from the url
  215. // use empty hash and state to avoid scrolling
  216. window.location.hash = ' ';
  217. window.history.pushState(null, null, ' ');
  218. }
  219. });
  220. }
  221. export async function handleReply(el: HTMLElement) {
  222. const form = el.closest('.comment-code-cloud').querySelector('.comment-form');
  223. const textarea = form.querySelector('textarea');
  224. hideElem(el);
  225. showElem(form);
  226. const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor'));
  227. editor.focus();
  228. return editor;
  229. }
  230. export function initRepoPullRequestReview() {
  231. if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
  232. const commentDiv = document.querySelector(window.location.hash);
  233. if (commentDiv) {
  234. // get the name of the parent id
  235. const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
  236. if (groupID && groupID.startsWith('code-comments-')) {
  237. const id = groupID.slice(14);
  238. const ancestorDiffBox = commentDiv.closest<HTMLElement>('.diff-file-box');
  239. hideElem(`#show-outdated-${id}`);
  240. showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`);
  241. // if the comment box is folded, expand it
  242. if (ancestorDiffBox?.getAttribute('data-folded') === 'true') {
  243. setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file'), false);
  244. }
  245. }
  246. // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
  247. if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
  248. // wait for a while because some elements (eg: image, editor, etc.) may change the viewport's height.
  249. setTimeout(() => commentDiv.scrollIntoView({block: 'start'}), 100);
  250. }
  251. }
  252. addDelegatedEventListener(document, 'click', '.show-outdated', (el, e) => {
  253. e.preventDefault();
  254. const id = el.getAttribute('data-comment');
  255. hideElem(el);
  256. showElem(`#code-comments-${id}`);
  257. showElem(`#code-preview-${id}`);
  258. showElem(`#hide-outdated-${id}`);
  259. });
  260. addDelegatedEventListener(document, 'click', '.hide-outdated', (el, e) => {
  261. e.preventDefault();
  262. const id = el.getAttribute('data-comment');
  263. hideElem(el);
  264. hideElem(`#code-comments-${id}`);
  265. hideElem(`#code-preview-${id}`);
  266. showElem(`#show-outdated-${id}`);
  267. });
  268. addDelegatedEventListener(document, 'click', 'button.comment-form-reply', (el, e) => {
  269. e.preventDefault();
  270. handleReply(el);
  271. });
  272. // The following part is only for diff views
  273. if (!document.querySelector('.repository.pull.diff')) return;
  274. const elReviewBtn = document.querySelector('.js-btn-review');
  275. const elReviewPanel = document.querySelector('.review-box-panel.tippy-target');
  276. if (elReviewBtn && elReviewPanel) {
  277. const tippy = createTippy(elReviewBtn, {
  278. content: elReviewPanel,
  279. theme: 'default',
  280. placement: 'bottom',
  281. trigger: 'click',
  282. maxWidth: 'none',
  283. interactive: true,
  284. hideOnClick: true,
  285. });
  286. elReviewPanel.querySelector('.close').addEventListener('click', () => tippy.hide());
  287. }
  288. addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
  289. e.preventDefault();
  290. const isSplit = el.closest('.code-diff')?.classList.contains('code-diff-split');
  291. const side = el.getAttribute('data-side');
  292. const idx = el.getAttribute('data-idx');
  293. const path = el.closest('[data-path]')?.getAttribute('data-path');
  294. const tr = el.closest('tr');
  295. const lineType = tr.getAttribute('data-line-type');
  296. let ntr = tr.nextElementSibling;
  297. if (!ntr?.classList.contains('add-comment')) {
  298. ntr = createElementFromHTML(`
  299. <tr class="add-comment" data-line-type="${lineType}">
  300. ${isSplit ? `
  301. <td class="add-comment-left" colspan="4"></td>
  302. <td class="add-comment-right" colspan="4"></td>
  303. ` : `
  304. <td class="add-comment-left add-comment-right" colspan="5"></td>
  305. `}
  306. </tr>`);
  307. tr.after(ntr);
  308. }
  309. const td = ntr.querySelector(`.add-comment-${side}`);
  310. const commentCloud = td.querySelector('.comment-code-cloud');
  311. if (!commentCloud && !ntr.querySelector('button[name="pending_review"]')) {
  312. const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url'));
  313. td.innerHTML = await response.text();
  314. td.querySelector<HTMLInputElement>("input[name='line']").value = idx;
  315. td.querySelector<HTMLInputElement>("input[name='side']").value = (side === 'left' ? 'previous' : 'proposed');
  316. td.querySelector<HTMLInputElement>("input[name='path']").value = path;
  317. const editor = await initComboMarkdownEditor(td.querySelector<HTMLElement>('.combo-markdown-editor'));
  318. editor.focus();
  319. }
  320. });
  321. }
  322. export function initRepoIssueReferenceIssue() {
  323. const elDropdown = document.querySelector('.issue_reference_repository_search');
  324. if (!elDropdown) return;
  325. const form = elDropdown.closest('form');
  326. fomanticQuery(elDropdown).dropdown({
  327. fullTextSearch: true,
  328. apiSettings: {
  329. cache: false,
  330. rawResponse: true,
  331. url: `${appSubUrl}/repo/search?q={query}&limit=20`,
  332. onResponse(response: any) {
  333. const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
  334. for (const repo of response.data) {
  335. filteredResponse.results.push({
  336. name: htmlEscape(repo.repository.full_name),
  337. value: repo.repository.full_name,
  338. });
  339. }
  340. return filteredResponse;
  341. },
  342. },
  343. onChange(_value: string, _text: string, _$choice: any) {
  344. form.setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
  345. },
  346. });
  347. // Reference issue
  348. addDelegatedEventListener(document, 'click', '.reference-issue', (el, e) => {
  349. e.preventDefault();
  350. const target = el.getAttribute('data-target');
  351. const content = document.querySelector(`#${target}`)?.textContent ?? '';
  352. const poster = el.getAttribute('data-poster-username');
  353. const reference = toAbsoluteUrl(el.getAttribute('data-reference'));
  354. const modalSelector = el.getAttribute('data-modal');
  355. const modal = document.querySelector(modalSelector);
  356. const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]');
  357. textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
  358. fomanticQuery(modal).modal('show');
  359. });
  360. }
  361. export function initRepoIssueWipNewTitle() {
  362. // Toggle WIP for new PR
  363. queryElems(document, '.title_wip_desc > a', (el) => el.addEventListener('click', (e) => {
  364. e.preventDefault();
  365. const wipPrefixes = JSON.parse(el.closest('.title_wip_desc').getAttribute('data-wip-prefixes'));
  366. const titleInput = document.querySelector<HTMLInputElement>('#issue_title');
  367. const titleValue = titleInput.value;
  368. for (const prefix of wipPrefixes) {
  369. if (titleValue.startsWith(prefix.toUpperCase())) {
  370. return;
  371. }
  372. }
  373. titleInput.value = `${wipPrefixes[0]} ${titleValue}`;
  374. }));
  375. }
  376. export function initRepoIssueWipToggle() {
  377. // Toggle WIP for existing PR
  378. registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => {
  379. e.preventDefault();
  380. const title = toggleWip.getAttribute('data-title');
  381. const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
  382. const updateUrl = toggleWip.getAttribute('data-update-url');
  383. const params = new URLSearchParams();
  384. params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
  385. const response = await POST(updateUrl, {data: params});
  386. if (!response.ok) {
  387. showErrorToast(`Failed to toggle 'work in progress' status`);
  388. return;
  389. }
  390. window.location.reload();
  391. }));
  392. }
  393. export function initRepoIssueTitleEdit() {
  394. const issueTitleDisplay = document.querySelector('#issue-title-display');
  395. const issueTitleEditor = document.querySelector<HTMLFormElement>('#issue-title-editor');
  396. if (!issueTitleEditor) return;
  397. const issueTitleInput = issueTitleEditor.querySelector('input');
  398. const oldTitle = issueTitleInput.getAttribute('data-old-title');
  399. issueTitleDisplay.querySelector('#issue-title-edit-show').addEventListener('click', () => {
  400. hideElem(issueTitleDisplay);
  401. hideElem('#pull-desc-display');
  402. showElem(issueTitleEditor);
  403. showElem('#pull-desc-editor');
  404. if (!issueTitleInput.value.trim()) {
  405. issueTitleInput.value = oldTitle;
  406. }
  407. issueTitleInput.focus();
  408. });
  409. issueTitleEditor.querySelector('.ui.cancel.button').addEventListener('click', () => {
  410. hideElem(issueTitleEditor);
  411. hideElem('#pull-desc-editor');
  412. showElem(issueTitleDisplay);
  413. showElem('#pull-desc-display');
  414. });
  415. const pullDescEditor = document.querySelector('#pull-desc-editor'); // it may not exist for a merged PR
  416. const prTargetUpdateUrl = pullDescEditor?.getAttribute('data-target-update-url');
  417. const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button');
  418. issueTitleEditor.addEventListener('submit', async (e) => {
  419. e.preventDefault();
  420. const newTitle = issueTitleInput.value.trim();
  421. try {
  422. if (newTitle && newTitle !== oldTitle) {
  423. const resp = await POST(editSaveButton.getAttribute('data-update-url'), {data: new URLSearchParams({title: newTitle})});
  424. if (!resp.ok) {
  425. throw new Error(`Failed to update issue title: ${resp.statusText}`);
  426. }
  427. }
  428. if (prTargetUpdateUrl) {
  429. const newTargetBranch = document.querySelector('#pull-target-branch').getAttribute('data-branch');
  430. const oldTargetBranch = document.querySelector('#branch_target').textContent;
  431. if (newTargetBranch !== oldTargetBranch) {
  432. const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: newTargetBranch})});
  433. if (!resp.ok) {
  434. throw new Error(`Failed to update PR target branch: ${resp.statusText}`);
  435. }
  436. }
  437. }
  438. ignoreAreYouSure(issueTitleEditor);
  439. window.location.reload();
  440. } catch (error) {
  441. console.error(error);
  442. showErrorToast(error.message);
  443. }
  444. });
  445. }
  446. export function initRepoIssueBranchSelect() {
  447. document.querySelector<HTMLElement>('#branch-select')?.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
  448. const el = e.target.closest('.item[data-branch]');
  449. if (!el) return;
  450. const pullTargetBranch = document.querySelector('#pull-target-branch');
  451. const baseName = pullTargetBranch.getAttribute('data-basename');
  452. const branchNameNew = el.getAttribute('data-branch');
  453. const branchNameOld = pullTargetBranch.getAttribute('data-branch');
  454. pullTargetBranch.textContent = pullTargetBranch.textContent.replace(`${baseName}:${branchNameOld}`, `${baseName}:${branchNameNew}`);
  455. pullTargetBranch.setAttribute('data-branch', branchNameNew);
  456. });
  457. }
  458. async function initSingleCommentEditor(commentForm: HTMLFormElement) {
  459. // pages:
  460. // * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content)
  461. // * issue/pr view page: with comment form, has status-button and comment-button
  462. const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor'));
  463. const statusButton = document.querySelector<HTMLButtonElement>('#status-button');
  464. const commentButton = document.querySelector<HTMLButtonElement>('#comment-button');
  465. const syncUiState = () => {
  466. const editorText = editor.value().trim(), isUploading = editor.isUploading();
  467. if (statusButton) {
  468. statusButton.textContent = statusButton.getAttribute(editorText ? 'data-status-and-comment' : 'data-status');
  469. statusButton.disabled = isUploading;
  470. }
  471. if (commentButton) {
  472. commentButton.disabled = !editorText || isUploading;
  473. }
  474. };
  475. editor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
  476. editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, syncUiState);
  477. syncUiState();
  478. }
  479. function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
  480. // pages:
  481. // * new issue with issue template
  482. const comboFields = commentForm.querySelectorAll<HTMLElement>('.combo-editor-dropzone');
  483. const initCombo = async (elCombo: HTMLElement) => {
  484. const fieldTextarea = elCombo.querySelector<HTMLTextAreaElement>('.form-field-real');
  485. const dropzoneContainer = elCombo.querySelector<HTMLElement>('.form-field-dropzone');
  486. const markdownEditor = elCombo.querySelector<HTMLElement>('.combo-markdown-editor');
  487. const editor = await initComboMarkdownEditor(markdownEditor);
  488. editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => fieldTextarea.value = editor.value());
  489. fieldTextarea.addEventListener('focus', async () => {
  490. // deactivate all markdown editors
  491. showElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-real'));
  492. hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .combo-markdown-editor'));
  493. queryElems(commentForm, '.combo-editor-dropzone .form-field-dropzone', (dropzoneContainer) => {
  494. // if "form-field-dropzone" exists, then "dropzone" must also exist
  495. const dropzone = dropzoneContainer.querySelector<HTMLElement>('.dropzone').dropzone;
  496. const hasUploadedFiles = dropzone.files.length !== 0;
  497. toggleElem(dropzoneContainer, hasUploadedFiles);
  498. });
  499. // activate this markdown editor
  500. hideElem(fieldTextarea);
  501. showElem(markdownEditor);
  502. showElem(dropzoneContainer);
  503. await editor.switchToUserPreference();
  504. editor.focus();
  505. });
  506. };
  507. for (const el of comboFields) {
  508. initCombo(el);
  509. }
  510. }
  511. export function initRepoCommentFormAndSidebar() {
  512. const commentForm = document.querySelector<HTMLFormElement>('.comment.form');
  513. if (!commentForm) return;
  514. if (commentForm.querySelector('.field.combo-editor-dropzone')) {
  515. // at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form
  516. initIssueTemplateCommentEditors(commentForm);
  517. } else if (commentForm.querySelector('.combo-markdown-editor')) {
  518. // it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
  519. initSingleCommentEditor(commentForm);
  520. }
  521. initRepoIssueSidebar();
  522. }