diff options
author | wxiaoguang <wxiaoguang@gmail.com> | 2025-03-01 10:02:10 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-03-01 02:02:10 +0000 |
commit | 698ae7aa5b12439dddcde59b040580dafdfa7381 (patch) | |
tree | 1474893eb2ea3887d60f8e212d80e21a0f84707e /web_src | |
parent | f3ada610972e5df5f82997af1630422e021aa62c (diff) | |
download | gitea-698ae7aa5b12439dddcde59b040580dafdfa7381.tar.gz gitea-698ae7aa5b12439dddcde59b040580dafdfa7381.zip |
Fix dynamic content loading init problem (#33748)
1. Rewrite `dirauto.ts` to `observer.ts`.
* We have been using MutationObserver for long time, it's proven that it
is quite performant.
* Now we extend its ability to handle more "init" works.
2. Use `observeAddedElement` to init all non-custom "dropdown".
3. Use `data-global-click` to handle click events from dynamically
loaded elements.
* By this new approach, the old fragile selector-based
(`.comment-reaction-button`) mechanism is removed.
4. By the way, remove unused `.diff-box` selector, it was abused and
never really used.
A lot of FIXMEs in "repo-diff.ts" are completely fixed, newly loaded
contents could work as expected.
Diffstat (limited to 'web_src')
-rw-r--r-- | web_src/css/repo.css | 4 | ||||
-rw-r--r-- | web_src/js/features/common-page.ts | 79 | ||||
-rw-r--r-- | web_src/js/features/comp/ReactionSelector.ts | 48 | ||||
-rw-r--r-- | web_src/js/features/repo-diff.ts | 15 | ||||
-rw-r--r-- | web_src/js/features/repo-graph.ts | 5 | ||||
-rw-r--r-- | web_src/js/index.ts | 4 | ||||
-rw-r--r-- | web_src/js/modules/dirauto.ts | 44 | ||||
-rw-r--r-- | web_src/js/modules/observer.ts | 89 |
8 files changed, 162 insertions, 126 deletions
diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 2752174f86..87af299dad 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1085,10 +1085,6 @@ td .commit-summary { height: 30px; } -.repository .diff-box .resolved-placeholder .button { - padding: 8px 12px; -} - .repository .diff-file-box .header { background-color: var(--color-box-header); } diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts index 56c5915b6d..c5274201f2 100644 --- a/web_src/js/features/common-page.ts +++ b/web_src/js/features/common-page.ts @@ -2,6 +2,7 @@ import {GET} from '../modules/fetch.ts'; import {showGlobalErrorMessage} from '../bootstrap.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {queryElems} from '../utils/dom.ts'; +import {observeAddedElement} from '../modules/observer.ts'; const {appUrl} = window.config; @@ -28,47 +29,51 @@ export function initFootLanguageMenu() { } export function initGlobalDropdown() { - // Semantic UI modules. - const $uiDropdowns = fomanticQuery('.ui.dropdown'); - // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. - $uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'}); + observeAddedElement('.ui.dropdown:not(.custom)', (el) => { + const $dropdown = fomanticQuery(el); + if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it. - // The "jump" means this dropdown is mainly used for "menu" purpose, - // clicking an item will jump to somewhere else or trigger an action/function. - // When a dropdown is used for non-refresh actions with tippy, - // it must have this "jump" class to hide the tippy when dropdown is closed. - $uiDropdowns.filter('.jump').dropdown('setting', { - action: 'hide', - onShow() { - // hide associated tooltip while dropdown is open - this._tippy?.hide(); - this._tippy?.disable(); - }, - onHide() { - this._tippy?.enable(); - // eslint-disable-next-line unicorn/no-this-assignment - const elDropdown = this; + $dropdown.dropdown('setting', {hideDividers: 'empty'}); - // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu - setTimeout(() => { - const $dropdown = fomanticQuery(elDropdown); - if ($dropdown.dropdown('is hidden')) { - queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide()); - } - }, 2000); - }, - }); + if (el.classList.contains('jump')) { + // The "jump" means this dropdown is mainly used for "menu" purpose, + // clicking an item will jump to somewhere else or trigger an action/function. + // When a dropdown is used for non-refresh actions with tippy, + // it must have this "jump" class to hide the tippy when dropdown is closed. + $dropdown.dropdown('setting', { + action: 'hide', + onShow() { + // hide associated tooltip while dropdown is open + this._tippy?.hide(); + this._tippy?.disable(); + }, + onHide() { + this._tippy?.enable(); + // eslint-disable-next-line unicorn/no-this-assignment + const elDropdown = this; - // Special popup-directions, prevent Fomantic from guessing the popup direction. - // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward, - // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view. - // eg: Issue List "Sort" dropdown - // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position, - // which would make some dropdown popups slightly shift out of the right viewport edge in some cases. - // eg: the "Create New Repo" menu on the navbar. - $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward'); - $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward'); + // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu + setTimeout(() => { + const $dropdown = fomanticQuery(elDropdown); + if ($dropdown.dropdown('is hidden')) { + queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide()); + } + }, 2000); + }, + }); + } + + // Special popup-directions, prevent Fomantic from guessing the popup direction. + // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward, + // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view. + // eg: Issue List "Sort" dropdown + // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position, + // which would make some dropdown popups slightly shift out of the right viewport edge in some cases. + // eg: the "Create New Repo" menu on the navbar. + if (el.classList.contains('upward')) $dropdown.dropdown('setting', 'direction', 'upward'); + if (el.classList.contains('downward')) $dropdown.dropdown('setting', 'direction', 'downward'); + }); } export function initGlobalTabularMenu() { diff --git a/web_src/js/features/comp/ReactionSelector.ts b/web_src/js/features/comp/ReactionSelector.ts index e93e3b8377..bb54593f11 100644 --- a/web_src/js/features/comp/ReactionSelector.ts +++ b/web_src/js/features/comp/ReactionSelector.ts @@ -1,37 +1,31 @@ import {POST} from '../../modules/fetch.ts'; -import {fomanticQuery} from '../../modules/fomantic/base.ts'; import type {DOMEvent} from '../../utils/dom.ts'; +import {registerGlobalEventFunc} from '../../modules/observer.ts'; -export function initCompReactionSelector(parent: ParentNode = document) { - for (const container of parent.querySelectorAll<HTMLElement>('.issue-content, .diff-file-body')) { - container.addEventListener('click', async (e: DOMEvent<MouseEvent>) => { - // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment - const target = e.target.closest('.comment-reaction-button'); - if (!target) return; - e.preventDefault(); +export function initCompReactionSelector() { + registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: DOMEvent<MouseEvent>) => { + // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment + e.preventDefault(); - if (target.classList.contains('disabled')) return; + if (target.classList.contains('disabled')) return; - const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url'); - const reactionContent = target.getAttribute('data-reaction-content'); + const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url'); + const reactionContent = target.getAttribute('data-reaction-content'); - const commentContainer = target.closest('.comment-container'); + const commentContainer = target.closest('.comment-container'); - const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction - const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`); - const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true'; + const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction + const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`); + const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true'; - const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { - data: new URLSearchParams({content: reactionContent}), - }); - - const data = await res.json(); - bottomReactions?.remove(); - if (data.html) { - commentContainer.insertAdjacentHTML('beforeend', data.html); - const bottomReactionsDropdowns = commentContainer.querySelectorAll('.bottom-reactions .dropdown.select-reaction'); - fomanticQuery(bottomReactionsDropdowns).dropdown(); // re-init the dropdown - } + const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { + data: new URLSearchParams({content: reactionContent}), }); - } + + const data = await res.json(); + bottomReactions?.remove(); + if (data.html) { + commentContainer.insertAdjacentHTML('beforeend', data.html); + } + }); } diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 5bf3782719..f1e441a133 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -1,4 +1,3 @@ -import {initCompReactionSelector} from './comp/ReactionSelector.ts'; import {initRepoIssueContentHistory} from './repo-issue-content.ts'; import {initDiffFileTree} from './repo-diff-filetree.ts'; import {initDiffCommitSelect} from './repo-diff-commitselect.ts'; @@ -8,17 +7,16 @@ import {initImageDiff} from './imagediff.ts'; import {showErrorToast} from '../modules/toast.ts'; import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts'; import {POST, GET} from '../modules/fetch.ts'; -import {fomanticQuery} from '../modules/fomantic/base.ts'; import {createTippy} from '../modules/tippy.ts'; import {invertFileFolding} from './file-fold.ts'; import {parseDom} from '../utils.ts'; +import {observeAddedElement} from '../modules/observer.ts'; const {i18n} = window.config; -function initRepoDiffFileViewToggle() { +function initRepoDiffFileBox(el: HTMLElement) { // switch between "rendered" and "source", for image and CSV files - // FIXME: this event listener is not correctly added to "load more files" - queryElems(document, '.file-view-toggle', (btn) => btn.addEventListener('click', () => { + queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => { queryElemSiblings(btn, '.file-view-toggle', (el) => el.classList.remove('active')); btn.classList.add('active'); @@ -75,7 +73,6 @@ function initRepoDiffConversationForm() { el.classList.add('tw-invisible'); } } - fomanticQuery(newConversationHolder.querySelectorAll('.ui.dropdown')).dropdown(); // the default behavior is to add a pending review, so if no submitter, it also means "pending_review" if (!submitter || submitter?.matches('button[name="pending_review"]')) { @@ -110,8 +107,6 @@ function initRepoDiffConversationForm() { if (elConversationHolder) { const elNewConversation = createElementFromHTML(data); elConversationHolder.replaceWith(elNewConversation); - queryElems(elConversationHolder, '.ui.dropdown:not(.custom)', (el) => fomanticQuery(el).dropdown()); - initCompReactionSelector(elNewConversation); } else { window.location.reload(); } @@ -149,7 +144,7 @@ function initDiffHeaderPopup() { // Will be called when the show more (files) button has been pressed function onShowMoreFiles() { - // FIXME: here the init calls are incomplete: at least it misses dropdown & initCompReactionSelector & initRepoDiffFileViewToggle + // TODO: replace these calls with the "observer.ts" methods initRepoIssueContentHistory(); initViewedCheckboxListenerFor(); countAndUpdateViewedFiles(); @@ -255,11 +250,11 @@ export function initRepoDiffView() { initDiffCommitSelect(); initRepoDiffShowMore(); initDiffHeaderPopup(); - initRepoDiffFileViewToggle(); initViewedCheckboxListenerFor(); initExpandAndCollapseFilesButton(); initRepoDiffHashChangeListener(); + observeAddedElement('#diff-file-boxes .diff-file-box', initRepoDiffFileBox); addDelegatedEventListener(document, 'click', '.fold-file', (el) => { invertFileFolding(el.closest('.file-content'), el); }); diff --git a/web_src/js/features/repo-graph.ts b/web_src/js/features/repo-graph.ts index 6d1629a1c1..7579ee42c6 100644 --- a/web_src/js/features/repo-graph.ts +++ b/web_src/js/features/repo-graph.ts @@ -83,8 +83,8 @@ export function initRepoGraphGit() { } const flowSelectRefsDropdown = document.querySelector('#flow-select-refs-dropdown'); - fomanticQuery(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected); - fomanticQuery(flowSelectRefsDropdown).dropdown({ + const $dropdown = fomanticQuery(flowSelectRefsDropdown); + $dropdown.dropdown({ clearable: true, fullTextSeach: 'exact', onRemove(toRemove: string) { @@ -110,6 +110,7 @@ export function initRepoGraphGit() { updateGraph(); }, }); + $dropdown.dropdown('set selected', dropdownSelected); graphContainer.addEventListener('mouseenter', (e: DOMEvent<MouseEvent>) => { if (e.target.matches('#rev-list li')) { diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 032e6ffe1b..2e253870c0 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -62,7 +62,7 @@ import {initRepoContributors} from './features/contributors.ts'; import {initRepoCodeFrequency} from './features/code-frequency.ts'; import {initRepoRecentCommits} from './features/recent-commits.ts'; import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts'; -import {initDirAuto} from './modules/dirauto.ts'; +import {initAddedElementObserver} from './modules/observer.ts'; import {initRepositorySearch} from './features/repo-search.ts'; import {initColorPickers} from './features/colorpicker.ts'; import {initAdminSelfCheck} from './features/admin/selfcheck.ts'; @@ -86,7 +86,7 @@ import { } from './features/common-form.ts'; initGiteaFomantic(); -initDirAuto(); +initAddedElementObserver(); initSubmitEventPolyfill(); function callInitFunctions(functions: (() => any)[]) { diff --git a/web_src/js/modules/dirauto.ts b/web_src/js/modules/dirauto.ts deleted file mode 100644 index 7058a59b09..0000000000 --- a/web_src/js/modules/dirauto.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; - -type DirElement = HTMLInputElement | HTMLTextAreaElement; - -// for performance considerations, it only uses performant syntax -function attachDirAuto(el: DirElement) { - if (el.type !== 'hidden' && - el.type !== 'checkbox' && - el.type !== 'radio' && - el.type !== 'range' && - el.type !== 'color') { - el.dir = 'auto'; - } -} - -export function initDirAuto(): void { - const observer = new MutationObserver((mutationList) => { - const len = mutationList.length; - for (let i = 0; i < len; i++) { - const mutation = mutationList[i]; - const len = mutation.addedNodes.length; - for (let i = 0; i < len; i++) { - const addedNode = mutation.addedNodes[i] as HTMLElement; - if (!isDocumentFragmentOrElementNode(addedNode)) continue; - if (addedNode.nodeName === 'INPUT' || addedNode.nodeName === 'TEXTAREA') { - attachDirAuto(addedNode as DirElement); - } - const children = addedNode.querySelectorAll<DirElement>('input, textarea'); - const len = children.length; - for (let childIdx = 0; childIdx < len; childIdx++) { - attachDirAuto(children[childIdx]); - } - } - } - }); - - const docNodes = document.querySelectorAll<DirElement>('input, textarea'); - const len = docNodes.length; - for (let i = 0; i < len; i++) { - attachDirAuto(docNodes[i]); - } - - observer.observe(document, {subtree: true, childList: true}); -} diff --git a/web_src/js/modules/observer.ts b/web_src/js/modules/observer.ts new file mode 100644 index 0000000000..57d0db31c7 --- /dev/null +++ b/web_src/js/modules/observer.ts @@ -0,0 +1,89 @@ +import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; + +type DirElement = HTMLInputElement | HTMLTextAreaElement; + +// for performance considerations, it only uses performant syntax +function attachDirAuto(el: Partial<DirElement>) { + if (el.type !== 'hidden' && + el.type !== 'checkbox' && + el.type !== 'radio' && + el.type !== 'range' && + el.type !== 'color') { + el.dir = 'auto'; + } +} + +type GlobalInitFunc<T extends HTMLElement> = (el: T) => void | Promise<void>; +const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {}; +function attachGlobalInit(el: HTMLElement) { + const initFunc = el.getAttribute('data-global-init'); + const func = globalInitFuncs[initFunc]; + if (!func) throw new Error(`Global init function "${initFunc}" not found`); + func(el); +} + +type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => (void | Promise<void>); +const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {}; +export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) { + globalEventFuncs[`${event}:${name}`] = func as any; +} + +type SelectorHandler = { + selector: string, + handler: (el: HTMLElement) => void, +}; + +const selectorHandlers: SelectorHandler[] = [ + {selector: 'input, textarea', handler: attachDirAuto}, + {selector: '[data-global-init]', handler: attachGlobalInit}, +]; + +export function observeAddedElement(selector: string, handler: (el: HTMLElement) => void) { + selectorHandlers.push({selector, handler}); + const docNodes = document.querySelectorAll<HTMLElement>(selector); + for (const el of docNodes) { + handler(el); + } +} + +export function initAddedElementObserver(): void { + const observer = new MutationObserver((mutationList) => { + const len = mutationList.length; + for (let i = 0; i < len; i++) { + const mutation = mutationList[i]; + const len = mutation.addedNodes.length; + for (let i = 0; i < len; i++) { + const addedNode = mutation.addedNodes[i] as HTMLElement; + if (!isDocumentFragmentOrElementNode(addedNode)) continue; + + for (const {selector, handler} of selectorHandlers) { + if (addedNode.matches(selector)) { + handler(addedNode); + } + const children = addedNode.querySelectorAll<HTMLElement>(selector); + for (const el of children) { + handler(el); + } + } + } + } + }); + + for (const {selector, handler} of selectorHandlers) { + const docNodes = document.querySelectorAll<HTMLElement>(selector); + for (const el of docNodes) { + handler(el); + } + } + + observer.observe(document, {subtree: true, childList: true}); + + document.addEventListener('click', (e) => { + const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]'); + if (!elem) return; + const funcName = elem.getAttribute('data-global-click'); + const func = globalEventFuncs[`click:${funcName}`]; + if (!func) throw new Error(`Global event function "click:${funcName}" not found`); + func(elem, e); + }); +} |