diff options
29 files changed, 244 insertions, 202 deletions
diff --git a/contributing.md b/contributing.md index 2a094394..7b01ced8 100644 --- a/contributing.md +++ b/contributing.md @@ -38,16 +38,20 @@ Here's an example using all of the possible `feature.add` options: ```ts import React from 'dom-chef'; import select from 'select-dom'; +import delegate, {DelegateSubscription} from 'delegate-it'; import features from '../libs/features'; +let delegate; function log() { console.log('✨', <div className="rgh-jsx-element"/>); } function init(): void { - select('.btn')!.addEventListener('click', log); + // Events must be set via delegate, unless shortlived + delegate = delegate('.btn', 'click', log); } function deinit(): void { - select('.btn')!.removeEventListener('load', log); + delegate?.destroy(); + delegate = undefined; } features.add({ diff --git a/source/features/batch-open-issues.tsx b/source/features/batch-open-issues.tsx index f85c62a7..0a53a3ee 100644 --- a/source/features/batch-open-issues.tsx +++ b/source/features/batch-open-issues.tsx @@ -1,5 +1,6 @@ import React from 'dom-chef'; import select from 'select-dom'; +import delegate from 'delegate-it'; import features from '../libs/features'; const confirmationRequiredCount = 10; @@ -35,13 +36,14 @@ function init(): void | false { return false; } + delegate('.rgh-batch-open-issues', 'click', openIssues); + const filtersBar = select('.table-list-header-toggle:not(.states)'); if (filtersBar) { filtersBar.prepend( <button type="button" - onClick={openIssues} - className="btn-link rgh-open-all-selected pr-2" + className="btn-link rgh-batch-open-issues pr-2" > Open All </button> @@ -54,8 +56,7 @@ function init(): void | false { triageFiltersBar.append( <button type="button" - onClick={openIssues} - className="btn-link rgh-open-all-selected pl-3" + className="btn-link rgh-batch-open-issues pl-3" > Open all </button> diff --git a/source/features/copy-file.tsx b/source/features/copy-file.tsx index 6a110fa8..6b4660ab 100644 --- a/source/features/copy-file.tsx +++ b/source/features/copy-file.tsx @@ -1,10 +1,10 @@ import React from 'dom-chef'; import select from 'select-dom'; -import delegate from 'delegate-it'; +import delegate, {DelegateEvent} from 'delegate-it'; import copyToClipboard from 'copy-text-to-clipboard'; import features from '../libs/features'; -function handleClick({currentTarget: button}: React.MouseEvent<HTMLButtonElement>): void { +function handleClick({delegateTarget: button}: DelegateEvent<MouseEvent, HTMLButtonElement>): void { const file = button.closest('.Box, .js-gist-file-update-container'); const content = select.all('.blob-code-inner', file!) .map(({innerText: line}) => line === '\n' ? '' : line) // Must be `.innerText` @@ -25,7 +25,6 @@ function renderButton(): void { .parentElement! // `BtnGroup` .prepend( <button - onClick={handleClick} className="btn btn-sm tooltipped tooltipped-n BtnGroup-item rgh-copy-file" aria-label="Copy file to clipboard" type="button"> @@ -35,15 +34,16 @@ function renderButton(): void { } } +function removeButton(): void { + select('.rgh-copy-file')?.remove(); +} + function init(): void { + delegate('.rgh-copy-file', 'click', handleClick); + if (select.exists('.blob.instapaper_body')) { delegate('.rgh-md-source', 'rgh:view-markdown-source', renderButton); - delegate('.rgh-md-source', 'rgh:view-markdown-rendered', () => { - const button = select('.rgh-copy-file'); - if (button) { - button.remove(); - } - }); + delegate('.rgh-md-source', 'rgh:view-markdown-rendered', removeButton); } else { renderButton(); } diff --git a/source/features/cycle-lists-with-keyboard-shortcuts.tsx b/source/features/cycle-lists-with-keyboard-shortcuts.tsx index 2077b323..a0283167 100644 --- a/source/features/cycle-lists-with-keyboard-shortcuts.tsx +++ b/source/features/cycle-lists-with-keyboard-shortcuts.tsx @@ -46,12 +46,8 @@ function init(): void { // Input fields for projects and milestones are added dynamically to the page // GitHub triggers events on the document element for us, which can be used to detect new input elements - delegate(document, '.js-filterable-field', 'filterable:change', event => { - populateSelectableItems(); - - const input = event.delegateTarget as HTMLElement; - input.addEventListener('keydown', handleKeyDown); - }); + delegate(document, '.js-filterable-field', 'filterable:change', populateSelectableItems); + delegate(document, '.js-filterable-field', 'keydown', handleKeyDown); } features.add({ diff --git a/source/features/default-to-rich-diff.tsx b/source/features/default-to-rich-diff.tsx index f2c5a7b8..95c13438 100644 --- a/source/features/default-to-rich-diff.tsx +++ b/source/features/default-to-rich-diff.tsx @@ -1,4 +1,5 @@ import select from 'select-dom'; +import delegate from 'delegate-it'; import features from '../libs/features'; function run(): void { @@ -30,9 +31,7 @@ function init(): void { run(); // Some files are loaded progressively later. On load, look for more buttons and more fragments - for (const fragment of select.all('include-fragment.diff-progressive-loader')) { - fragment.addEventListener('load', init); - } + delegate('include-fragment.diff-progressive-loader', 'load', run); } features.add({ diff --git a/source/features/extend-diff-expander.tsx b/source/features/extend-diff-expander.tsx index cfca695a..6581d567 100644 --- a/source/features/extend-diff-expander.tsx +++ b/source/features/extend-diff-expander.tsx @@ -11,7 +11,7 @@ function expandDiff(event: DelegateEvent): void { } function init(): void { - delegate('.diff-view', '.js-expandable-line', 'click', expandDiff); + delegate('.diff-view .js-expandable-line', 'click', expandDiff); } features.add({ diff --git a/source/features/fix-view-file-link-in-pr.tsx b/source/features/fix-view-file-link-in-pr.tsx index 6b561910..75a4e605 100644 --- a/source/features/fix-view-file-link-in-pr.tsx +++ b/source/features/fix-view-file-link-in-pr.tsx @@ -36,7 +36,7 @@ function handleMenuOpening(event: DelegateEvent): void { } function init(): void { - delegate('#files', '.js-file-header-dropdown > summary', 'click', handleMenuOpening); + delegate('.js-file-header-dropdown > summary', 'click', handleMenuOpening); } features.add({ diff --git a/source/features/hidden-review-comments-indicator.tsx b/source/features/hidden-review-comments-indicator.tsx index 4ae15126..3ba23e64 100644 --- a/source/features/hidden-review-comments-indicator.tsx +++ b/source/features/hidden-review-comments-indicator.tsx @@ -3,15 +3,16 @@ import mem from 'mem'; import React from 'dom-chef'; import select from 'select-dom'; import commentIcon from 'octicon/comment.svg'; +import delegate, {DelegateEvent} from 'delegate-it'; import features from '../libs/features'; import anchorScroll from '../libs/anchor-scroll'; import onPrFileLoad from '../libs/on-pr-file-load'; // When an indicator is clicked, this will show comments on the current file -const handleIndicatorClick = ({currentTarget}: React.MouseEvent<HTMLElement>): void => { - const commentedLine = currentTarget.closest('tr')!.previousElementSibling!; +const handleIndicatorClick = ({delegateTarget}: DelegateEvent): void => { + const commentedLine = delegateTarget.closest('tr')!.previousElementSibling!; const resetScroll = anchorScroll(commentedLine); - currentTarget + delegateTarget .closest('.file.js-file')! .querySelector<HTMLInputElement>('.js-toggle-file-notes')! .click(); @@ -25,7 +26,7 @@ const addIndicator = mem((commentThread: HTMLElement): void => { commentThread.before( <tr> - <td className="rgh-comments-indicator blob-num" colSpan={2} onClick={handleIndicatorClick}> + <td className="rgh-comments-indicator blob-num" colSpan={2}> <button type="button" className="btn-link"> {commentIcon()} <span>{commentCount}</span> @@ -63,6 +64,7 @@ function observeFiles(): void { function init(): void { observeFiles(); onPrFileLoad(observeFiles); + delegate('.rgh-comments-indicator', 'click', handleIndicatorClick); } features.add({ diff --git a/source/features/hide-comments-faster.tsx b/source/features/hide-comments-faster.tsx index 14fa9165..50bebeeb 100644 --- a/source/features/hide-comments-faster.tsx +++ b/source/features/hide-comments-faster.tsx @@ -3,22 +3,21 @@ import select from 'select-dom'; import delegate, {DelegateEvent} from 'delegate-it'; import features from '../libs/features'; -function handleMenuOpening(event: DelegateEvent): void { - const hideButton = select('.js-comment-hide-button', event.delegateTarget.parentElement!); - if (!hideButton) { - // User unable to hide or menu already created +function generateSubmenu(hideButton: Element): void { + if (hideButton.closest('.rgh-hide-comments-faster-details')) { + // Already generated return; } - // Disable default behavior - hideButton.classList.remove('js-comment-hide-button'); + const detailsElement = hideButton.closest('details')!; + detailsElement.classList.add('rgh-hide-comments-faster-details'); const comment = hideButton.closest('.unminimized-comment')!; - const form = select('.js-comment-minimize', comment)!; + const hideCommentForm = select('.js-comment-minimize', comment)!; // Generate dropdown items for (const reason of select.all<HTMLInputElement>('[name="classifier"] :not([value=""])', comment)) { - form.append( + hideCommentForm.append( <button name="classifier" value={reason.value} @@ -30,33 +29,44 @@ function handleMenuOpening(event: DelegateEvent): void { } // Drop previous form controls - select('.btn', form)!.remove(); - select('[name="classifier"]', form)!.remove(); + select('.btn', hideCommentForm)!.remove(); + select('[name="classifier"]', hideCommentForm)!.remove(); // Imitate existing menu - form.classList.add('dropdown-menu', 'dropdown-menu-sw', 'text-gray-dark', 'show-more-popover', 'anim-scale-in'); + hideCommentForm.classList.add('dropdown-menu', 'dropdown-menu-sw', 'text-gray-dark', 'show-more-popover', 'anim-scale-in'); - // Show menu on top of optionList when "Hide" is clicked - // Hide it when dropdown closes. - // Uses `v-hidden` and `d-none` to avoid conflicts with `close-out-of-view-modals` + detailsElement.append(hideCommentForm); +} + +// Shows menu on top of mainDropdownContent when "Hide" is clicked; +// Hide it when dropdown closes. +// Uses `v-hidden` and `d-none` to avoid conflicts with `close-out-of-view-modals` +function toggleSubMenu(hideButton: Element, show: boolean): void { const dropdown = hideButton.closest('details')!; - const optionList = select('.show-more-popover', dropdown)!; - hideButton.addEventListener('click', event => { - event.stopImmediatePropagation(); - event.preventDefault(); - optionList.classList.add('v-hidden'); - form.classList.remove('d-none'); - }); - dropdown.addEventListener('toggle', () => { - optionList.classList.remove('v-hidden'); - form.classList.add('d-none'); - }); - dropdown.append(form); + // Native dropdown + select('details-menu', dropdown)!.classList.toggle('v-hidden', show); + + // "Hide comment" dropdown + select('form.js-comment-minimize', dropdown)!.classList.toggle('d-none', !show); +} + +function resetDropdowns(event: DelegateEvent): void { + toggleSubMenu(event.delegateTarget, false); +} + +function showSubmenu(event: DelegateEvent): void { + generateSubmenu(event.delegateTarget); + toggleSubMenu(event.delegateTarget, true); + + event.stopImmediatePropagation(); + event.preventDefault(); } function init(): void { - delegate('.timeline-comment-action', 'click', handleMenuOpening); + // `useCapture` required to be fired before GitHub's handlers + delegate('.js-comment-hide-button', 'click', showSubmenu, true); + delegate('.rgh-hide-comments-faster-details', 'toggle', resetDropdowns, true); } features.add({ diff --git a/source/features/hide-useless-comments.tsx b/source/features/hide-useless-comments.tsx index be0b2ac1..edac8fa1 100644 --- a/source/features/hide-useless-comments.tsx +++ b/source/features/hide-useless-comments.tsx @@ -1,6 +1,7 @@ import './hide-useless-comments.css'; import React from 'dom-chef'; import select from 'select-dom'; +import delegate, {DelegateEvent} from 'delegate-it'; import features from '../libs/features'; function init(): void { @@ -40,19 +41,20 @@ function init(): void { select('.discussion-timeline-actions')!.prepend( <p className="rgh-useless-comments-note"> {`${uselessCount} unhelpful comment${uselessCount > 1 ? 's were' : ' was'} automatically hidden. `} - <button className="btn-link text-emphasized" onClick={unhide}>Show</button> + <button className="btn-link text-emphasized rgh-unhide-useless-comments">Show</button> </p> ); + delegate('.rgh-unhide-useless-comments', 'click', unhide); } } -function unhide(event: React.MouseEvent<HTMLButtonElement>): void { +function unhide(event: DelegateEvent<MouseEvent, HTMLButtonElement>): void { for (const comment of select.all('.rgh-hidden-comment')) { comment.hidden = false; } select('.rgh-hidden-comment')!.scrollIntoView(); - event.currentTarget.parentElement!.remove(); + event.delegateTarget.parentElement!.remove(); } features.add({ diff --git a/source/features/limit-commit-title-length.tsx b/source/features/limit-commit-title-length.tsx index 81d60111..ac94fc09 100644 --- a/source/features/limit-commit-title-length.tsx +++ b/source/features/limit-commit-title-length.tsx @@ -1,28 +1,28 @@ import './limit-commit-title-length.css'; import select from 'select-dom'; +import delegate, {DelegateEvent} from 'delegate-it'; import features from '../libs/features'; import onPrMergePanelOpen from '../libs/on-pr-merge-panel-open'; -function init(): void { - const inputField = select<HTMLInputElement>([ - '#commit-summary-input', // Commit title on edit file page - '#merge_title_field' // PR merge message field - ]); +const fieldSelector = [ + '#commit-summary-input', // Commit title on edit file page + '#merge_title_field' // PR merge message field +].join(); - // The input field doesn't exist on PR merge page if you don't have access to that repo - if (!inputField) { - return; - } +function validateInput({delegateTarget: inputField}: DelegateEvent<InputEvent, HTMLInputElement>): void { + inputField.setCustomValidity(inputField.value.length > 72 ? `The title should be maximum 72 characters, but is ${inputField.value.length}` : ''); +} - inputField.addEventListener('input', () => { - inputField.setCustomValidity(inputField.value.length > 72 ? `The title should be maximum 72 characters, but is ${inputField.value.length}` : ''); - }); +function triggerValidation(): void { + select(fieldSelector)!.dispatchEvent(new Event('input')); +} + +function init(): void { + delegate(fieldSelector, 'input', validateInput); // For PR merges, GitHub restores any saved commit messages on page load // Triggering input event for these fields immediately validates the form - onPrMergePanelOpen(() => { - inputField.dispatchEvent(new Event('input')); - }); + onPrMergePanelOpen(triggerValidation); } features.add({ diff --git a/source/features/mark-unread.tsx b/source/features/mark-unread.tsx index 373800da..dc8a2846 100644 --- a/source/features/mark-unread.tsx +++ b/source/features/mark-unread.tsx @@ -71,7 +71,7 @@ function stripHash(url: string): string { function addMarkUnreadButton(): void { if (!select.exists('.rgh-btn-mark-unread')) { select('.thread-subscription-status')!.after( - <button className="btn btn-sm btn-block mt-2 rgh-btn-mark-unread" onClick={markUnread}> + <button className="btn btn-sm btn-block mt-2 rgh-btn-mark-unread"> Mark as unread </button> ); @@ -96,7 +96,7 @@ async function markRead(urls: string|string[]): Promise<void> { await setNotifications(updated); } -async function markUnread({currentTarget}: React.MouseEvent): Promise<void> { +async function markUnread({delegateTarget}: DelegateEvent): Promise<void> { const participants: Participant[] = select.all('.participant-avatar').slice(0, 3).map(element => ({ username: element.getAttribute('aria-label')!, avatar: element.querySelector('img')!.src @@ -135,8 +135,8 @@ async function markUnread({currentTarget}: React.MouseEvent): Promise<void> { await setNotifications(unreadNotifications); await updateUnreadIndicator(); - currentTarget.setAttribute('disabled', 'disabled'); - currentTarget.textContent = 'Marked as unread'; + delegateTarget.setAttribute('disabled', 'disabled'); + delegateTarget.textContent = 'Marked as unread'; } function getNotification(notification: Notification): Element { @@ -423,6 +423,7 @@ async function init(): Promise<void> { if (pageDetect.isPRConversation() || pageDetect.isIssue()) { addMarkUnreadButton(); onUpdatableContentUpdate(select('#partial-discussion-sidebar')!, addMarkUnreadButton); + delegate('.rgh-btn-mark-unread', 'click', markUnread); } } else if (pageDetect.isDiscussionList()) { for (const discussion of await getNotifications()) { diff --git a/source/features/minimize-user-comments.tsx b/source/features/minimize-user-comments.tsx index 79c9b940..1cb6e6d1 100644 --- a/source/features/minimize-user-comments.tsx +++ b/source/features/minimize-user-comments.tsx @@ -32,8 +32,8 @@ function toggleComment(comment: HTMLElement, minimize: boolean): void { select('.minimized-comment .Details-element', comment)!.toggleAttribute('open', !minimize); } -async function onButtonClick(event: React.MouseEvent<HTMLButtonElement>): Promise<void> { - const comment = event.currentTarget.closest('.js-targetable-comment')!; +async function handleMinimizeClick(event: DelegateEvent): Promise<void> { + const comment = event.delegateTarget.closest('.js-targetable-comment')!; const user = getUsernameFromComment(comment); let minimizedUsers = await getMinimizedUsers(); @@ -71,7 +71,7 @@ async function handleMenuOpening(event: DelegateEvent): Promise<void> { className="dropdown-item btn-link rgh-minimize-user-comments-button" role="menuitem" type="button" - onClick={onButtonClick}> + > {getLabel(shouldMinimizeComment)} </button> ); @@ -94,6 +94,7 @@ function init(): void { onNewComments(minimizeMutedUserComments); // `summary` excludes the `edit-comments-faster` button delegate('summary.timeline-comment-action:not([aria-label="Add your reaction"])', 'click', handleMenuOpening); + delegate('.rgh-minimize-user-comments-button', 'click', handleMinimizeClick); } features.add({ diff --git a/source/features/pr-branch-auto-delete.tsx b/source/features/pr-branch-auto-delete.tsx index acfd28fa..bdeadaf2 100644 --- a/source/features/pr-branch-auto-delete.tsx +++ b/source/features/pr-branch-auto-delete.tsx @@ -4,7 +4,7 @@ import features from '../libs/features'; import observeEl from '../libs/simplified-element-observer'; function init(): void { - const [subscription] = delegate('#discussion_bucket', '.js-merge-commit-button', 'click', () => { + const subscription = delegate('.js-merge-commit-button', 'click', () => { subscription.destroy(); observeEl('.discussion-timeline-actions', (_, observer) => { diff --git a/source/features/pr-filters.tsx b/source/features/pr-filters.tsx index 490fae35..2e3ae059 100644 --- a/source/features/pr-filters.tsx +++ b/source/features/pr-filters.tsx @@ -1,14 +1,18 @@ import React from 'dom-chef'; import select from 'select-dom'; +import delegate, {DelegateEvent} from 'delegate-it'; import checkIcon from 'octicon/check.svg'; import features from '../libs/features'; import {getIcon as fetchCIStatus} from './ci-link'; -let currentQuerySegments: string[]; +// The Reviews dropdown doesn't have a specific class, so we expect this sequence (span contains Projects and Milestones) +const reviewsFilterSelector = '#label-select-menu + span + details'; function addDropdownItem(dropdown: HTMLElement, title: string, filterCategory: string, filterValue: string): void { const filterQuery = `${filterCategory}:${filterValue}`; + const searchParameter = new URLSearchParams(location.search); + const currentQuerySegments = searchParameter.get('q')?.split(/\s+/) ?? []; const isSelected = currentQuerySegments.some( segment => segment.toLowerCase() === filterQuery ); @@ -34,7 +38,14 @@ function addDropdownItem(dropdown: HTMLElement, title: string, filterCategory: s ); } -function addDraftFilter(reviewsFilter: HTMLElement): void { +const hasDraftFilter = new WeakSet(); +function addDraftFilter({delegateTarget: reviewsFilter}: DelegateEvent): void { + if (hasDraftFilter.has(reviewsFilter)) { + return; + } + + hasDraftFilter.add(reviewsFilter); + const dropdown = select('.select-menu-list', reviewsFilter)!; dropdown.append( @@ -47,7 +58,12 @@ function addDraftFilter(reviewsFilter: HTMLElement): void { addDropdownItem(dropdown, 'Not ready for review (Draft PR)', 'draft', 'true'); } -async function addStatusFilter(reviewsFilter: HTMLElement): Promise<void> { +async function addStatusFilter(): Promise<void> { + const reviewsFilter = select(reviewsFilterSelector); + if (!reviewsFilter) { + return; + } + // TODO: replace this with an API call const hasCI = await fetchCIStatus(); if (!hasCI) { @@ -70,17 +86,8 @@ async function addStatusFilter(reviewsFilter: HTMLElement): Promise<void> { } function init(): void { - const reviewsFilter = select('.table-list-header-toggle > details:nth-last-child(3)'); - - if (!reviewsFilter) { - return; - } - - const searchParameter = new URLSearchParams(location.search); - currentQuerySegments = (searchParameter.get('q') ?? '').split(/\s+/); - - reviewsFilter.addEventListener('toggle', () => addDraftFilter(reviewsFilter), {once: true}); - addStatusFilter(reviewsFilter); + delegate(reviewsFilterSelector, 'toggle', addDraftFilter, true); + addStatusFilter(); } features.add({ diff --git a/source/features/quick-mention.tsx b/source/features/quick-mention.tsx index 5f215d26..eca1e6be 100644 --- a/source/features/quick-mention.tsx +++ b/source/features/quick-mention.tsx @@ -43,7 +43,7 @@ function init(): void | false { ); } - delegate('#discussion_bucket', 'button.rgh-quick-mention', 'click', mentionUser); + delegate('button.rgh-quick-mention', 'click', mentionUser); } features.add({ diff --git a/source/features/quick-review-buttons.tsx b/source/features/quick-review-buttons.tsx index 86cc35e8..8d47266b 100644 --- a/source/features/quick-review-buttons.tsx +++ b/source/features/quick-review-buttons.tsx @@ -74,6 +74,7 @@ function init(): false | void { }); // Freeze form to avoid duplicate submissions + // TODO: maybe `data-disable-with` can do this like it does for the generic "Comment" button form.addEventListener('submit', () => { // Delay disabling the fields to let them be submitted first setTimeout(() => { diff --git a/source/features/raw-file-link.tsx b/source/features/raw-file-link.tsx index 34f35e01..efd583ae 100644 --- a/source/features/raw-file-link.tsx +++ b/source/features/raw-file-link.tsx @@ -23,7 +23,7 @@ function handleMenuOpening(event: DelegateEvent): void { } function init(): void { - delegate('#files', '.js-file-header-dropdown > summary', 'click', handleMenuOpening); + delegate('.js-file-header-dropdown > summary', 'click', handleMenuOpening); } features.add({ diff --git a/source/features/revert-file.tsx b/source/features/revert-file.tsx index 9221d9cf..c0b0e25b 100644 --- a/source/features/revert-file.tsx +++ b/source/features/revert-file.tsx @@ -72,10 +72,17 @@ async function commitFileContent(menuItem: Element, content: string): Promise<vo await postForm(form); } -async function handleRevertFileClick(event: React.MouseEvent<HTMLButtonElement>): Promise<void> { - const menuItem = event.currentTarget; - // Allow only one click - menuItem.removeEventListener('click', handleRevertFileClick as unknown as EventListener); +const filesReverted = new WeakSet<HTMLButtonElement>(); +async function handleRevertFileClick(event: DelegateEvent<MouseEvent, HTMLButtonElement>): Promise<void> { + const menuItem = event.delegateTarget; + + // Only allow one click + if (filesReverted.has(menuItem)) { + return; + } + + filesReverted.add(menuItem); + menuItem.textContent = 'Reverting…'; event.preventDefault(); event.stopPropagation(); @@ -120,7 +127,6 @@ function handleMenuOpening(event: DelegateEvent): void { style={{whiteSpace: 'pre-wrap'}} role="menuitem" type="button" - onClick={handleRevertFileClick} > Revert changes </button> @@ -128,7 +134,8 @@ function handleMenuOpening(event: DelegateEvent): void { } function init(): void { - delegate('#files', '.js-file-header-dropdown > summary', 'click', handleMenuOpening); + delegate('.js-file-header-dropdown > summary', 'click', handleMenuOpening); + delegate('.rgh-revert-file', 'click', handleRevertFileClick, true); } features.add({ diff --git a/source/features/submit-review-as-single-comment.tsx b/source/features/submit-review-as-single-comment.tsx index 32238e28..e2b44c05 100644 --- a/source/features/submit-review-as-single-comment.tsx +++ b/source/features/submit-review-as-single-comment.tsx @@ -101,8 +101,8 @@ async function handleSubmitSingle(event: DelegateEvent): Promise<void> { } function init(): void { - delegate('#files', '[action$="/review_comment/create"]', 'submit', handleReviewSubmission); - delegate('#files', '.rgh-submit-single', 'click', handleSubmitSingle); + delegate('#files [action$="/review_comment/create"]', 'submit', handleReviewSubmission); + delegate('.rgh-submit-single', 'click', handleSubmitSingle); updateUI(); } diff --git a/source/features/sync-pr-commit-title.tsx b/source/features/sync-pr-commit-title.tsx index 78e0b686..93eea6e0 100644 --- a/source/features/sync-pr-commit-title.tsx +++ b/source/features/sync-pr-commit-title.tsx @@ -1,8 +1,7 @@ import React from 'dom-chef'; import select from 'select-dom'; -import onetime from 'onetime'; import debounce from 'debounce-fn'; -import delegate, {DelegateSubscription} from 'delegate-it'; +import delegate, {DelegateSubscription, DelegateEvent} from 'delegate-it'; import insertTextTextarea from 'insert-text-textarea'; import features from '../libs/features'; import onPrMergePanelOpen from '../libs/on-pr-merge-panel-open'; @@ -26,19 +25,23 @@ const createCommitTitle = debounce<[], string>((): string => { immediate: true }); -const getNote = onetime<[], HTMLElement>((): HTMLElement => - <p className="note"> - The title of this PR will be updated to match this title. <button type="button" className="btn-link muted-link text-underline" onClick={event => { - deinit(); - event.currentTarget.parentElement!.remove(); // Hide note - }}>Cancel</button> - </p> -); +function getNote(): HTMLElement { + return select('.note.rgh-sync-pr-commit-title-note') ?? ( + <p className="note rgh-sync-pr-commit-title-note"> + The title of this PR will be updated to match this title. <button type="button" className="btn-link muted-link text-underline rgh-sync-pr-commit-title">Cancel</button> + </p> + ); +} function getPRNumber(): string { return select('.gh-header-number')!.textContent!; } +function handleCancelClick(event: DelegateEvent): void { + deinit(); + event.delegateTarget.parentElement!.remove(); // Hide note +} + function maybeShowNote(): void { const inputField = select<HTMLInputElement>('#merge_title_field')!; const needsSubmission = createCommitTitle() !== inputField.value; @@ -89,9 +92,10 @@ function onMergePanelOpen(event: Event): void { let listeners: DelegateSubscription[]; function init(): void { listeners = [ - ...delegate('#discussion_bucket', '#merge_title_field', 'input', maybeShowNote), - ...delegate('#discussion_bucket', 'form.js-merge-pull-request', 'submit', submitPRTitleUpdate), - ...onPrMergePanelOpen(onMergePanelOpen) + delegate('#merge_title_field', 'input', maybeShowNote), + delegate('form.js-merge-pull-request', 'submit', submitPRTitleUpdate), + delegate('.rgh-sync-pr-commit-title', 'click', handleCancelClick), + onPrMergePanelOpen(onMergePanelOpen) ]; } diff --git a/source/features/toggle-everything-with-alt.tsx b/source/features/toggle-everything-with-alt.tsx index cc9aea48..a2ac4ab5 100644 --- a/source/features/toggle-everything-with-alt.tsx +++ b/source/features/toggle-everything-with-alt.tsx @@ -7,13 +7,13 @@ type EventHandler = (event: DelegateEvent<MouseEvent, HTMLElement>) => void; function init(): void { // Collapsed comments in PR conversations and files - delegate('.repository-content', '.minimized-comment details summary', 'click', clickAll(minimizedCommentsSelector)); + delegate('.minimized-comment details summary', 'click', clickAll(minimizedCommentsSelector)); // "Load diff" buttons in PR files - delegate('.repository-content', '.js-file .js-diff-load', 'click', clickAll(allDiffsSelector)); + delegate('.js-file .js-diff-load', 'click', clickAll(allDiffsSelector)); // Review comments in PR - delegate('.repository-content', '.js-file .js-resolvable-thread-toggler', 'click', clickAll(resolvedCommentsSelector)); + delegate('.js-file .js-resolvable-thread-toggler', 'click', clickAll(resolvedCommentsSelector)); } function clickAll(selectorGetter: ((clickedItem: HTMLElement) => string)): EventHandler { diff --git a/source/features/update-pr-from-base-branch.tsx b/source/features/update-pr-from-base-branch.tsx index bc487809..b1f476aa 100644 --- a/source/features/update-pr-from-base-branch.tsx +++ b/source/features/update-pr-from-base-branch.tsx @@ -99,7 +99,7 @@ function init(): void | false { } observer = observeEl('.discussion-timeline-actions', addButton)!; - delegate('.discussion-timeline-actions', '.rgh-update-pr-from-master', 'click', handler); + delegate('.rgh-update-pr-from-master', 'click', handler); } features.add({ diff --git a/source/features/wait-for-build.tsx b/source/features/wait-for-build.tsx index 4947cb8f..b9169dbd 100644 --- a/source/features/wait-for-build.tsx +++ b/source/features/wait-for-build.tsx @@ -90,13 +90,11 @@ function init(): false | void { onPrMergePanelOpen(showCheckboxIfNecessary); - const container = select('.discussion-timeline-actions')!; - // One of the merge buttons has been clicked - delegate(container, '.js-merge-commit-button', 'click', handleMergeConfirmation); + delegate('.js-merge-commit-button', 'click', handleMergeConfirmation); // Cancel wait when the user presses the Cancel button - delegate(container, '.commit-form-actions button:not(.js-merge-commit-button)', 'click', () => { + delegate('.commit-form-actions button:not(.js-merge-commit-button)', 'click', () => { disableForm(false); }); diff --git a/source/features/warning-for-disallow-edits.tsx b/source/features/warning-for-disallow-edits.tsx index e7ca4b83..8432870c 100644 --- a/source/features/warning-for-disallow-edits.tsx +++ b/source/features/warning-for-disallow-edits.tsx @@ -1,33 +1,40 @@ import './warning-for-disallow-edits.css'; import React from 'dom-chef'; import select from 'select-dom'; +import oneTime from 'onetime'; +import delegate, {DelegateEvent} from 'delegate-it'; import features from '../libs/features'; +const getWarning = oneTime(() => ( + <div className="flash flash-error mt-3 rgh-warning-for-disallow-edits"> + <strong>Note:</strong> Maintainers may require changes. It’s easier and faster to allow them to make direct changes before merging. + </div> +)); + +function update(checkbox: HTMLInputElement): void { + if (checkbox.checked) { + getWarning().remove(); + } else { + // Select every time because the sidebar content may be replaced + select(` + .new-pr-form .timeline-comment, + #partial-discussion-sidebar .js-collab-form + .js-dropdown-details + `)!.after(getWarning()); + } +} + +function toggleHandler(event: DelegateEvent<UIEvent, HTMLInputElement>): void { + update(event.delegateTarget); +} + function init(): void { const checkbox = select<HTMLInputElement>('[name="collab_privs"]'); if (!checkbox) { return; } - const warning = ( - <div className="flash flash-error mt-3 rgh-warning-for-disallow-edits"> - <strong>Note:</strong> Maintainers may require changes. It’s easier and faster to allow them to make direct changes before merging. - </div> - ); - const update = (): void => { - if (checkbox.checked) { - warning.remove(); - } else { - // Select every time because the sidebar content may be replaced - select(` - .new-pr-form .timeline-comment, - #partial-discussion-sidebar .js-collab-form + .js-dropdown-details - `)!.after(warning); - } - }; - - update(); // The sidebar checkbox may already be un-checked - checkbox.addEventListener('change', update); + update(checkbox); // The sidebar checkbox may already be un-checked + delegate('[name="collab_privs"]', 'change', toggleHandler); } features.add({ diff --git a/source/globals.d.ts b/source/globals.d.ts index e00f9431..76117070 100644 --- a/source/globals.d.ts +++ b/source/globals.d.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // TODO: Drop some definitions when their related bugs are resolved -// TODO: Improve JSX types for event listeners so we can use `MouseEvent` instead of `React.MouseEvent`, which is incompatible with regular `addEventListeners` calls type AnyObject = Record<string, any>; type AsyncVoidFunction = () => Promise<void>; @@ -36,6 +35,7 @@ interface GlobalEventHandlersEventMap { 'rgh:view-markdown-source': CustomEvent; 'rgh:view-markdown-rendered': CustomEvent; 'filterable:change': CustomEvent; + 'page:loaded': CustomEvent; } declare namespace JSX { diff --git a/source/libs/on-new-comments.ts b/source/libs/on-new-comments.ts index 26c7d43b..265ad867 100644 --- a/source/libs/on-new-comments.ts +++ b/source/libs/on-new-comments.ts @@ -1,44 +1,51 @@ import select from 'select-dom'; -import observeEl from './simplified-element-observer'; +import delegate, {DelegateSubscription} from 'delegate-it'; +const discussionsWithListeners = new WeakSet(); const handlers = new Set<VoidFunction>(); -const observed = new WeakSet(); +const delegates = new Set<DelegateSubscription>(); +const observer = new MutationObserver(run); function run(): void { // Run all callbacks without letting an error stop the loop and without silencing it handlers.forEach(async callback => callback()); } -// On new page loads, run the callbacks and look for the new elements. -// (addEventListener doesn't add duplicate listeners) -function addListenersOnNewElements(): void { - for (const loadMore of select.all('.js-ajax-pagination')) { - loadMore.addEventListener('page:loaded', run); - loadMore.addEventListener('page:loaded', addListenersOnNewElements); +function removeListeners(): void { + for (const subscription of delegates) { + subscription.destroy(); } - // Outdated comment are loaded later using an include-fragment element - for (const fragment of select.all('details.outdated-comment > include-fragment')) { - fragment.addEventListener('load', run); - } + delegates.clear(); + handlers.clear(); + observer.disconnect(); } -function setup(): void { +function addListeners(): void { const discussion = select('.js-discussion'); - if (!discussion || observed.has(discussion)) { + if (!discussion || discussionsWithListeners.has(discussion)) { return; } - observed.add(discussion); + // Ensure listeners are only ever added once + discussionsWithListeners.add(discussion); + + // Remember to remove all listeners when a new page is loaded + document.addEventListener('pjax:beforeReplace', removeListeners); // When new comments come in via AJAX - observeEl(discussion, run); + observer.observe(discussion, { + childList: true + }); // When hidden comments are loaded by clicking "Load more..." - addListenersOnNewElements(); + delegates.add(delegate('.js-ajax-pagination', 'page:loaded', run)); + + // Outdated comment are loaded later using an include-fragment element + delegates.add(delegate('details.outdated-comment > include-fragment', 'load', run, true)); } export default function (callback: VoidFunction): void { - setup(); + addListeners(); handlers.add(callback); } diff --git a/source/libs/on-pr-file-load.ts b/source/libs/on-pr-file-load.ts index b06c8d4a..1c3f3330 100644 --- a/source/libs/on-pr-file-load.ts +++ b/source/libs/on-pr-file-load.ts @@ -1,22 +1,19 @@ -import select from 'select-dom'; +import mem from 'mem'; +import delegate, {DelegateSubscription, DelegateEventHandler, DelegateEvent} from 'delegate-it'; -// In PRs' Files tab, some files are loaded progressively later. -const handlers = new WeakMap<EventListener, EventListener>(); +const fragmentSelector = [ + 'include-fragment.diff-progressive-loader', // Incremental file loader on scroll + 'include-fragment.js-diff-entry-loader' // File diff loader on clicking "Load Diff" +].join(); -export default function onPrFileLoad(callback: EventListener): void { - // When a fragment loads, more fragments might be nested in it. The following code avoids duplicate event handlers. - const recursiveCallback = handlers.get(callback) ?? ((event: Event) => { - callback(event); - onPrFileLoad(callback); - }); - handlers.set(callback, recursiveCallback); +// This lets you call `onPrFileLoad` multiple times with the same callback but only ever a `load` listener is registered +const getDeduplicatedHandler = mem((callback: EventListener): DelegateEventHandler => { + return (event: DelegateEvent) => event.delegateTarget.addEventListener('load', callback); +}); - const fragments = select.all([ - 'include-fragment.diff-progressive-loader', // Incremental file loader on scroll - 'include-fragment.js-diff-entry-loader' // File diff loader on clicking "Load Diff" - ].join()); - - for (const fragment of fragments) { - fragment.addEventListener('load', recursiveCallback); - } +export default function onPrFileLoad(callback: EventListener): DelegateSubscription { + // `loadstart` is fired when the fragment is still attached so event delegation works. + // `load` is fired after it’s detached, so `delegate` would never listen to it. + // This is why we listen to a global `loadstart` and then add a specific `load` listener on the element, which is fired even when the element is detached. + return delegate(fragmentSelector, 'loadstart', getDeduplicatedHandler(callback), true); } diff --git a/source/libs/on-pr-merge-panel-open.ts b/source/libs/on-pr-merge-panel-open.ts index d8df675b..6ed88d48 100644 --- a/source/libs/on-pr-merge-panel-open.ts +++ b/source/libs/on-pr-merge-panel-open.ts @@ -13,27 +13,25 @@ const sessionResumeHandler = mem((callback: EventListener) => async (event: Even callback(event); }); -export default function (callback: EventListener): DelegateSubscription[] { +export default function (callback: EventListener): DelegateSubscription { document.addEventListener( 'session:resume', sessionResumeHandler(callback) ); + const toggleSubscription = delegate( + '.js-merge-pr:not(.is-rebasing)', + 'details:toggled', + delegateHandler(callback) + ); - return [ - { - // Imitate a DelegateSubscription for this event as well - destroy() { - document.removeEventListener( - 'session:resume', - sessionResumeHandler(callback) - ); - } - }, - ...delegate( - '#discussion_bucket', - '.js-merge-pr:not(.is-rebasing)', - 'details:toggled', - delegateHandler(callback) - ) - ]; + // Imitate a DelegateSubscription for this event as well + return { + destroy() { + toggleSubscription.destroy(); + document.removeEventListener( + 'session:resume', + sessionResumeHandler(callback) + ); + } + }; } |