Migrate from webpack to vite (#37002)

Replace webpack with Vite 8 as the frontend bundler. Frontend build is
around 3-4 times faster than before. Will work on all platforms
including riscv64 (via wasm).

`iife.js` is a classic render-blocking script in `<head>` (handles web
components/early DOM setup). `index.js` is loaded as a `type="module"`
script in the footer. All other JS chunks are also module scripts
(supported in all browsers since 2018).

Entry filenames are content-hashed (e.g. `index.C6Z2MRVQ.js`) and
resolved at runtime via the Vite manifest, eliminating the `?v=` cache
busting (which was unreliable in some scenarios like vscode dev build).

Replaces: https://github.com/go-gitea/gitea/pull/36896
Fixes: https://github.com/go-gitea/gitea/issues/17793
Signed-off-by: silverwind <me@silverwind.io>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-03-29 12:24:30 +02:00
committed by GitHub
parent 6288c87181
commit 0ec66b5380
88 changed files with 1706 additions and 1727 deletions
+5 -77
View File
@@ -1,82 +1,12 @@
// DO NOT IMPORT window.config HERE!
// to make sure the error handler always works, we should never import `window.config`, because
// some user's custom template breaks it.
import type {Intent} from './types.ts';
import {html} from './utils/html.ts';
import {showGlobalErrorMessage, processWindowErrorEvent} from './modules/errors.ts';
// This sets up the URL prefix used in webpack's chunk loading.
// This file must be imported before any lazy-loading is being attempted.
window.__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
export function shouldIgnoreError(err: Error) {
const ignorePatterns: Array<RegExp> = [
// https://github.com/go-gitea/gitea/issues/30861
// https://github.com/microsoft/monaco-editor/issues/4496
// https://github.com/microsoft/monaco-editor/issues/4679
/\/assets\/js\/.*monaco/,
];
for (const pattern of ignorePatterns) {
if (pattern.test(err.stack ?? '')) return true;
}
return false;
}
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
const msgContainer = document.querySelector('.page-content') ?? document.body;
if (!msgContainer) {
alert(`${msgType}: ${msg}`);
return;
}
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
const el = document.createElement('div');
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
}
// merge duplicated messages into "the message (count)" format
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgContainer.prepend(msgDiv);
}
function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
const err = error ?? reason;
const assetBaseUrl = String(new URL(window.__webpack_public_path__, window.location.origin));
const {runModeIsProd} = window.config ?? {};
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
// non-critical event from the browser. We log them but don't show them to users. Examples:
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
// - https://github.com/go-gitea/gitea/issues/20240
if (!err) {
if (message) console.error(new Error(message));
if (runModeIsProd) return;
}
if (err instanceof Error) {
// If the error stack trace does not include the base URL of our script assets, it likely came
// from a browser extension or inline script. Do not show such errors in production.
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
// Ignore some known errors that are unable to fix
if (shouldIgnoreError(err)) return;
}
let msg = err?.message ?? message;
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
const dot = msg.endsWith('.') ? '' : '.';
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
}
function initGlobalErrorHandler() {
if (window._globalHandlerErrors?._inited) {
showGlobalErrorMessage(`The global error handler has been initialized, do not initialize it again`);
return;
}
// A module should not be imported twice, otherwise there will be bugs when a module has its internal states.
// A real example is "generateElemId" in "utils/dom.ts", if it is imported twice in different module scopes,
// It will generate duplicate IDs (ps: don't try to use "random" to fix, it is just a real example to show the importance of "do not import a module twice")
if (!window._globalHandlerErrors?._inited) {
if (!window.config) {
showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
}
@@ -90,5 +20,3 @@ function initGlobalErrorHandler() {
// events directly
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any;
}
initGlobalErrorHandler();
+1 -1
View File
@@ -34,7 +34,7 @@ export async function initCaptcha() {
break;
}
case 'm-captcha': {
const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
const mCaptcha = await import('@mcaptcha/vanilla-glue');
// FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run
// * the "vanilla-glue" has some problems with es6 module.
+4 -4
View File
@@ -6,10 +6,10 @@ const {pageData} = window.config;
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'),
import('@citation-js/core'),
import('@citation-js/plugin-software-formats'),
import('@citation-js/plugin-bibtex'),
import('@citation-js/plugin-csl'),
]);
const citationFileContent = pageData.citationFileContent!;
const config = plugins.config.get('@bibtex');
+1 -1
View File
@@ -4,7 +4,7 @@ export async function initRepoCodeFrequency() {
const el = document.querySelector('#repo-code-frequency-chart');
if (!el) return;
const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue');
const {default: RepoCodeFrequency} = await import('../components/RepoCodeFrequency.vue');
try {
const View = createApp(RepoCodeFrequency, {
locale: {
+1 -1
View File
@@ -129,7 +129,7 @@ function updateTheme(monaco: Monaco): void {
type CreateMonacoOpts = MonacoOpts & {language?: string};
export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, opts: CreateMonacoOpts): Promise<{monaco: Monaco, editor: IStandaloneCodeEditor}> {
const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor');
const monaco = await import('../modules/monaco.ts');
initLanguages(monaco);
let {language, ...other} = opts;
+2 -2
View File
@@ -6,8 +6,8 @@ export async function initColorPickers() {
registerGlobalInitFunc('initColorPicker', async (el) => {
if (!imported) {
await Promise.all([
import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'),
import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'),
import('vanilla-colorful/hex-color-picker.js'),
import('../../css/features/colorpicker.css'),
]);
imported = true;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {GET, POST} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../bootstrap.ts';
import {showGlobalErrorMessage} from '../modules/errors.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
@@ -319,8 +319,8 @@ export class ComboMarkdownEditor {
async switchToEasyMDE() {
if (this.easyMDE) return;
const [{default: EasyMDE}] = await Promise.all([
import(/* webpackChunkName: "easymde" */'easymde'),
import(/* webpackChunkName: "easymde" */'../../../css/easymde.css'),
import('easymde'),
import('../../../css/easymde.css'),
]);
const easyMDEOpt: EasyMDE.Options = {
autoDownloadFontAwesome: false,
+1 -1
View File
@@ -7,7 +7,7 @@ type CropperOpts = {
};
async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
const {default: Cropper} = await import('cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
+1 -1
View File
@@ -4,7 +4,7 @@ export async function initRepoContributors() {
const el = document.querySelector('#repo-contributors-chart');
if (!el) return;
const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
const {default: RepoContributors} = await import('../components/RepoContributors.vue');
try {
const View = createApp(RepoContributors, {
repoLink: el.getAttribute('data-repo-link'),
+2 -2
View File
@@ -19,8 +19,8 @@ export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([
import(/* webpackChunkName: "dropzone" */'dropzone'),
import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
import('dropzone'),
import('dropzone/dist/dropzone.css'),
]);
return new Dropzone(el, opts);
}
+1 -1
View File
@@ -45,7 +45,7 @@ export async function initHeatmap() {
noDataText: el.getAttribute('data-locale-no-contributions'),
};
const {default: ActivityHeatmap} = await import(/* webpackChunkName: "ActivityHeatmap" */ '../components/ActivityHeatmap.vue');
const {default: ActivityHeatmap} = await import('../components/ActivityHeatmap.vue');
const View = createApp(ActivityHeatmap, {values, locale});
View.mount(el);
el.classList.remove('is-loading');
+1 -1
View File
@@ -4,7 +4,7 @@ export async function initRepoRecentCommits() {
const el = document.querySelector('#repo-recent-commits-chart');
if (!el) return;
const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue');
const {default: RepoRecentCommits} = await import('../components/RepoRecentCommits.vue');
try {
const View = createApp(RepoRecentCommits, {
locale: {
+1 -1
View File
@@ -69,7 +69,7 @@ export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
export function initRepoFileSearch() {
registerGlobalInitFunc('initRepoFileSearch', async (el) => {
const {default: RepoFileSearch} = await import(/* webpackChunkName: "RepoFileSearch" */ '../components/RepoFileSearch.vue');
const {default: RepoFileSearch} = await import('../components/RepoFileSearch.vue');
createApp(RepoFileSearch, {
repoLink: el.getAttribute('data-repo-link'),
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
+1 -1
View File
@@ -66,7 +66,7 @@ async function initRepoPullRequestMergeForm(box: HTMLElement) {
const el = box.querySelector('#pull-request-merge-form');
if (!el) return;
const {default: PullRequestMergeForm} = await import(/* webpackChunkName: "PullRequestMergeForm" */ '../components/PullRequestMergeForm.vue');
const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
const view = createApp(PullRequestMergeForm);
view.mount(el);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type {TributeCollection} from 'tributejs';
import type {Mention} from '../types.ts';
export async function attachTribute(element: HTMLElement) {
const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
const {default: Tribute} = await import('tributejs');
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
const emojiCollection: TributeCollection<string> = { // emojis
+10 -1
View File
@@ -22,8 +22,8 @@ interface Window {
config: {
appUrl: string,
appSubUrl: string,
assetVersionEncoded: string,
assetUrlPrefix: string,
sharedWorkerUri: string,
runModeIsProd: boolean,
customEmojis: Record<string, string>,
pageData: Record<string, any> & {
@@ -64,6 +64,10 @@ interface Window {
codeEditors: any[], // export editor for customization
localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings,
MonacoEnvironment?: {
getWorker: (workerId: string, label: string) => Worker,
},
// various captcha plugins
grecaptcha: any,
turnstile: any,
@@ -71,3 +75,8 @@ interface Window {
// do not add more properties here unless it is a must
}
declare module '*?worker' {
const workerConstructor: new () => Worker;
export default workerConstructor;
}
+16 -2
View File
@@ -1,2 +1,16 @@
import jquery from 'jquery';
window.$ = window.jQuery = jquery; // only for Fomantic UI
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports
// Some users still use inline scripts and expect jQuery to be available globally.
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
window.$ = window.jQuery = jquery;
// There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
// The bug makes htmx impossible to be loaded from an ES module: importing the htmx in onDomReady will make htmx skip its initialization.
// ref: https://github.com/bigskysoftware/htmx/pull/3365
window.htmx = htmx;
// https://htmx.org/reference/#config
htmx.config.requestClass = 'is-loading';
htmx.config.scrollIntoViewOnBoost = false;
-26
View File
@@ -1,26 +0,0 @@
import htmx from 'htmx.org';
import 'idiomorph/htmx';
import type {HtmxResponseInfo} from 'htmx.org';
import {showErrorToast} from './modules/toast.ts';
type HtmxEvent = Event & {detail: HtmxResponseInfo};
export function initHtmx() {
window.htmx = htmx;
// https://htmx.org/reference/#config
htmx.config.requestClass = 'is-loading';
htmx.config.scrollIntoViewOnBoost = false;
// https://htmx.org/events/#htmx:sendError
document.body.addEventListener('htmx:sendError', (event: Partial<HtmxEvent>) => {
// TODO: add translations
showErrorToast(`Network error when calling ${event.detail!.requestConfig.path}`);
});
// https://htmx.org/events/#htmx:responseError
document.body.addEventListener('htmx:responseError', (event: Partial<HtmxEvent>) => {
// TODO: add translations
showErrorToast(`Error ${event.detail!.xhr.status} when calling ${event.detail!.requestConfig.path}`);
});
}
+11
View File
@@ -0,0 +1,11 @@
// This file is the entry point for the code which should block the page rendering, it is compiled by our "iife" vite plugin
// bootstrap module must be the first one to be imported, it handles global errors
import './bootstrap.ts';
// many users expect to use jQuery in their custom scripts (https://docs.gitea.com/administration/customizing-gitea#example-plantuml)
// so load globals (including jQuery) as early as possible
import './globals.ts';
import './webcomponents/index.ts';
import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts
-175
View File
@@ -1,175 +0,0 @@
import '../fomantic/build/fomantic.js';
import {initHtmx} from './htmx.ts';
import {initDashboardRepoList} from './features/dashboard.ts';
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
import {initRepoGraphGit} from './features/repo-graph.ts';
import {initHeatmap} from './features/heatmap.ts';
import {initImageDiff} from './features/imagediff.ts';
import {initRepoMigration} from './features/repo-migration.ts';
import {initRepoProject} from './features/repo-projects.ts';
import {initTableSort} from './features/tablesort.ts';
import {initAdminUserListSearchForm} from './features/admin/users.ts';
import {initAdminConfigs} from './features/admin/config.ts';
import {initMarkupAnchors} from './markup/anchors.ts';
import {initNotificationCount} from './features/notification.ts';
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts';
import {initRepoFileSearch} from './features/repo-findfile.ts';
import {initMarkupContent} from './markup/content.ts';
import {initRepoFileView} from './features/file-view.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
import {initAdminCommon} from './features/admin/common.ts';
import {initRepoCodeView} from './features/repo-code.ts';
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
import {initUserSettings} from './features/user-settings.ts';
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
import {initRepoDiffView} from './features/repo-diff.ts';
import {initOrgTeam} from './features/org-team.ts';
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
import {initRepoReleaseNew} from './features/repo-release.ts';
import {initRepoEditor} from './features/repo-editor.ts';
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
import {initInstall} from './features/install.ts';
import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
import {initRepoBranchButton} from './features/repo-branch.ts';
import {initCommonOrganization} from './features/common-organization.ts';
import {initRepoWikiForm} from './features/repo-wiki.ts';
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
import {initCopyContent} from './features/copycontent.ts';
import {initCaptcha} from './features/captcha.ts';
import {initRepositoryActionView} from './features/repo-actions.ts';
import {initGlobalTooltips} from './modules/tippy.ts';
import {initGiteaFomantic} from './modules/fomantic.ts';
import {initSubmitEventPolyfill} from './utils/dom.ts';
import {initRepoIssueList} from './features/repo-issue-list.ts';
import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts';
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 {initGlobalSelectorObserver} from './modules/observer.ts';
import {initRepositorySearch} from './features/repo-search.ts';
import {initColorPickers} from './features/colorpicker.ts';
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts';
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
import {initGlobalShortcut} from './modules/shortcut.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
initHtmx,
initSubmitEventPolyfill,
initGiteaFomantic,
initGlobalComponent,
initGlobalDropdown,
initGlobalFetchAction,
initGlobalTooltips,
initGlobalButtonClickOnEnter,
initGlobalButtons,
initGlobalCopyToClipboardListener,
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initGlobalInput,
initGlobalShortcut,
initCommonOrganization,
initCommonIssueListQuickGoto,
initCompSearchUserBox,
initCompWebHookEditor,
initInstall,
initCommmPageComponents,
initHeatmap,
initImageDiff,
initMarkupAnchors,
initMarkupContent,
initSshKeyFormParser,
initStopwatch,
initTableSort,
initRepoFileSearch,
initCopyContent,
initAdminCommon,
initAdminUserListSearchForm,
initAdminConfigs,
initAdminSelfCheck,
initDashboardRepoList,
initNotificationCount,
initOrgTeam,
initRepoActivityTopAuthorsChart,
initRepoArchiveLinks,
initRepoBranchButton,
initRepoCodeView,
initBranchSelectorTabs,
initRepoEllipsisButton,
initRepoDiffCommitBranchesAndTags,
initRepoEditor,
initRepoGraphGit,
initRepoIssueContentHistory,
initRepoIssueList,
initRepoIssueFilterItemLabel,
initRepoIssueSidebarDependency,
initRepoMigration,
initRepoMigrationStatusChecker,
initRepoProject,
initRepoPullRequestAllowMaintainerEdit,
initRepoPullRequestReview,
initRepoReleaseNew,
initRepoTopicBar,
initRepoViewFileTree,
initRepoWikiForm,
initRepository,
initRepositoryActionView,
initRepositorySearch,
initRepoContributors,
initRepoCodeFrequency,
initRepoRecentCommits,
initCommitStatuses,
initCaptcha,
initUserCheckAppUrl,
initUserAuthOauth2,
initUserAuthWebAuthn,
initUserAuthWebAuthnRegister,
initUserSettings,
initRepoDiffView,
initColorPickers,
initOAuth2SettingsDisableCheckbox,
initRepoFileView,
initActionsPermissionsForm,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
initGlobalSelectorObserver(initPerformanceTracer);
if (initPerformanceTracer) initPerformanceTracer.printResults();
const initDur = performance.now() - initStartTime;
if (initDur > 500) {
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
}
document.dispatchEvent(new CustomEvent('gitea:index-ready'));
+183 -24
View File
@@ -1,29 +1,188 @@
// bootstrap module must be the first one to be imported, it handles webpack lazy-loading and global errors
import './bootstrap.ts';
import '../fomantic/build/fomantic.js';
import '../css/index.css';
import type {HtmxResponseInfo} from 'htmx.org';
import {showErrorToast} from './modules/toast.ts';
// many users expect to use jQuery in their custom scripts (https://docs.gitea.com/administration/customizing-gitea#example-plantuml)
// so load globals (including jQuery) as early as possible
import './globals.ts';
import {initDashboardRepoList} from './features/dashboard.ts';
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
import {initRepoGraphGit} from './features/repo-graph.ts';
import {initHeatmap} from './features/heatmap.ts';
import {initImageDiff} from './features/imagediff.ts';
import {initRepoMigration} from './features/repo-migration.ts';
import {initRepoProject} from './features/repo-projects.ts';
import {initTableSort} from './features/tablesort.ts';
import {initAdminUserListSearchForm} from './features/admin/users.ts';
import {initAdminConfigs} from './features/admin/config.ts';
import {initMarkupAnchors} from './markup/anchors.ts';
import {initNotificationCount} from './features/notification.ts';
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts';
import {initRepoFileSearch} from './features/repo-findfile.ts';
import {initMarkupContent} from './markup/content.ts';
import {initRepoFileView} from './features/file-view.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
import {initAdminCommon} from './features/admin/common.ts';
import {initRepoCodeView} from './features/repo-code.ts';
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
import {initUserSettings} from './features/user-settings.ts';
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
import {initRepoDiffView} from './features/repo-diff.ts';
import {initOrgTeam} from './features/org-team.ts';
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
import {initRepoReleaseNew} from './features/repo-release.ts';
import {initRepoEditor} from './features/repo-editor.ts';
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
import {initInstall} from './features/install.ts';
import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
import {initRepoBranchButton} from './features/repo-branch.ts';
import {initCommonOrganization} from './features/common-organization.ts';
import {initRepoWikiForm} from './features/repo-wiki.ts';
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
import {initCopyContent} from './features/copycontent.ts';
import {initCaptcha} from './features/captcha.ts';
import {initRepositoryActionView} from './features/repo-actions.ts';
import {initGlobalTooltips} from './modules/tippy.ts';
import {initGiteaFomantic} from './modules/fomantic.ts';
import {initSubmitEventPolyfill} from './utils/dom.ts';
import {initRepoIssueList} from './features/repo-issue-list.ts';
import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts';
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 {initGlobalSelectorObserver} from './modules/observer.ts';
import {initRepositorySearch} from './features/repo-search.ts';
import {initColorPickers} from './features/colorpicker.ts';
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts';
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
import {initGlobalShortcut} from './modules/shortcut.ts';
import './webcomponents/index.ts';
import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts
import {onDomReady} from './utils/dom.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
initSubmitEventPolyfill,
initGiteaFomantic,
// TODO: There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
// Then importing the htmx in our onDomReady will make htmx skip its initialization.
// If the bug would be fixed (https://github.com/bigskysoftware/htmx/pull/3365), then we can only import htmx in "onDomReady"
import 'htmx.org';
initGlobalComponent,
initGlobalDropdown,
initGlobalFetchAction,
initGlobalTooltips,
initGlobalButtonClickOnEnter,
initGlobalButtons,
initGlobalCopyToClipboardListener,
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initGlobalInput,
initGlobalShortcut,
onDomReady(async () => {
// when navigate before the import complete, there will be an error from webpack chunk loader:
// JavaScript promise rejection: Loading chunk index-domready failed.
try {
await import(/* webpackChunkName: "index-domready" */'./index-domready.ts');
} catch (e) {
if (e.name === 'ChunkLoadError') {
console.error('Error loading index-domready:', e);
} else {
throw e;
}
}
initCommonOrganization,
initCommonIssueListQuickGoto,
initCompSearchUserBox,
initCompWebHookEditor,
initInstall,
initCommmPageComponents,
initHeatmap,
initImageDiff,
initMarkupAnchors,
initMarkupContent,
initSshKeyFormParser,
initStopwatch,
initTableSort,
initRepoFileSearch,
initCopyContent,
initAdminCommon,
initAdminUserListSearchForm,
initAdminConfigs,
initAdminSelfCheck,
initDashboardRepoList,
initNotificationCount,
initOrgTeam,
initRepoActivityTopAuthorsChart,
initRepoArchiveLinks,
initRepoBranchButton,
initRepoCodeView,
initBranchSelectorTabs,
initRepoEllipsisButton,
initRepoDiffCommitBranchesAndTags,
initRepoEditor,
initRepoGraphGit,
initRepoIssueContentHistory,
initRepoIssueList,
initRepoIssueFilterItemLabel,
initRepoIssueSidebarDependency,
initRepoMigration,
initRepoMigrationStatusChecker,
initRepoProject,
initRepoPullRequestAllowMaintainerEdit,
initRepoPullRequestReview,
initRepoReleaseNew,
initRepoTopicBar,
initRepoViewFileTree,
initRepoWikiForm,
initRepository,
initRepositoryActionView,
initRepositorySearch,
initRepoContributors,
initRepoCodeFrequency,
initRepoRecentCommits,
initCommitStatuses,
initCaptcha,
initUserCheckAppUrl,
initUserAuthOauth2,
initUserAuthWebAuthn,
initUserAuthWebAuthnRegister,
initUserSettings,
initRepoDiffView,
initColorPickers,
initOAuth2SettingsDisableCheckbox,
initRepoFileView,
initActionsPermissionsForm,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
initGlobalSelectorObserver(initPerformanceTracer);
if (initPerformanceTracer) initPerformanceTracer.printResults();
const initDur = performance.now() - initStartTime;
if (initDur > 500) {
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
}
// https://htmx.org/events/#htmx:sendError
type HtmxEvent = Event & {detail: HtmxResponseInfo};
document.body.addEventListener('htmx:sendError', (event) => {
// TODO: add translations
showErrorToast(`Network error when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
});
// https://htmx.org/events/#htmx:responseError
document.body.addEventListener('htmx:responseError', (event) => {
// TODO: add translations
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
});
document.dispatchEvent(new CustomEvent('gitea:index-ready'));
+2 -2
View File
@@ -3,8 +3,8 @@ import {queryElems} from '../utils/dom.ts';
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
const [player] = await Promise.all([
import(/* webpackChunkName: "asciinema-player" */'asciinema-player'),
import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
import('asciinema-player'),
import('asciinema-player/dist/bundle/asciinema-player.css'),
]);
player.create(el.getAttribute('data-asciinema-player-src')!, el, {
+2 -2
View File
@@ -16,8 +16,8 @@ export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> {
// .markup code.language-math'
queryElems(elMarkup, 'code.language-math', async (el) => {
const [{default: katex}] = await Promise.all([
import(/* webpackChunkName: "katex" */'katex'),
import(/* webpackChunkName: "katex" */'katex/dist/katex.css'),
import('katex'),
import('katex/dist/katex.css'),
]);
const MAX_CHARS = 1000;
+2 -2
View File
@@ -72,8 +72,8 @@ export function sourceNeedsElk(source: string) {
}
async function loadMermaid(needElkRender: boolean) {
const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid');
const elkPromise = needElkRender ? import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null;
const mermaidPromise = import('mermaid');
const elkPromise = needElkRender ? import('@mermaid-js/layout-elk') : null;
const results = await Promise.all([mermaidPromise, elkPromise]);
return {
mermaid: results[0].default,
+1 -1
View File
@@ -20,7 +20,7 @@ function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
const el = document.createElement('div');
const onShowAsync = async () => {
const {default: ContextPopup} = await import(/* webpackChunkName: "ContextPopup" */ '../components/ContextPopup.vue');
const {default: ContextPopup} = await import('../components/ContextPopup.vue');
const view = createApp(ContextPopup, {
// backend: GetIssueInfo
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
@@ -1,4 +1,4 @@
import {showGlobalErrorMessage, shouldIgnoreError} from './bootstrap.ts';
import {showGlobalErrorMessage, shouldIgnoreError} from './errors.ts';
test('showGlobalErrorMessage', () => {
document.body.innerHTML = '<div class="page-content"></div>';
@@ -13,9 +13,9 @@ test('showGlobalErrorMessage', () => {
test('shouldIgnoreError', () => {
for (const url of [
'https://gitea.test/assets/js/monaco.b359ef7e.js',
'https://gitea.test/assets/js/monaco-editor.4a969118.worker.js',
'https://gitea.test/assets/js/vendors-node_modules_pnpm_monaco-editor_0_55_1_node_modules_monaco-editor_esm_vs_base_common_-e11c7c.966a028d.js',
'https://gitea.test/assets/js/monaco.D14TzjS9.js',
'https://gitea.test/assets/js/editor.api2.BdhK7zNg.js',
'https://gitea.test/assets/js/editor.worker.BYgvyFya.js',
]) {
const err = new Error('test');
err.stack = `Error: test\n at ${url}:1:1`;
+67
View File
@@ -0,0 +1,67 @@
// keep this file lightweight, it's imported into IIFE chunk in bootstrap
import {html} from '../utils/html.ts';
import type {Intent} from '../types.ts';
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
const msgContainer = document.querySelector('.page-content') ?? document.body;
if (!msgContainer) {
alert(`${msgType}: ${msg}`);
return;
}
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
const el = document.createElement('div');
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
}
// merge duplicated messages into "the message (count)" format
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgContainer.prepend(msgDiv);
}
export function shouldIgnoreError(err: Error) {
const ignorePatterns: Array<RegExp> = [
// https://github.com/go-gitea/gitea/issues/30861
// https://github.com/microsoft/monaco-editor/issues/4496
// https://github.com/microsoft/monaco-editor/issues/4679
/\/assets\/js\/.*(monaco|editor\.(api|worker))/,
];
for (const pattern of ignorePatterns) {
if (pattern.test(err.stack ?? '')) return true;
}
return false;
}
export function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
const err = error ?? reason;
const assetBaseUrl = String(new URL(`${window.config?.assetUrlPrefix ?? '/assets'}/`, window.location.origin));
const {runModeIsProd} = window.config ?? {};
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
// non-critical event from the browser. We log them but don't show them to users. Examples:
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
// - https://github.com/go-gitea/gitea/issues/20240
if (!err) {
if (message) console.error(new Error(message));
if (runModeIsProd) return;
}
if (err instanceof Error) {
// If the error stack trace does not include the base URL of our script assets, it likely came
// from a browser extension or inline script. Do not show such errors in production.
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
// Ignore some known errors that are unable to fix
if (shouldIgnoreError(err)) return;
}
let msg = err?.message ?? message;
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
const dot = msg.endsWith('.') ? '' : '.';
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
}
-1
View File
@@ -1,4 +1,3 @@
import $ from 'jquery';
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
import {initAriaFormFieldPatch} from './fomantic/form.ts';
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
-1
View File
@@ -1,4 +1,3 @@
import $ from 'jquery';
import {generateElemId} from '../../utils/dom.ts';
export function linkLabelAndInput(label: Element, input: Element) {
-1
View File
@@ -1,4 +1,3 @@
import $ from 'jquery';
import {queryElemChildren} from '../../utils/dom.ts';
export function initFomanticDimmer() {
-1
View File
@@ -1,4 +1,3 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
import {generateElemId, queryElems} from '../../utils/dom.ts';
-1
View File
@@ -1,4 +1,3 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
import {queryElems} from '../../utils/dom.ts';
import {hideToastsFrom} from '../toast.ts';
-1
View File
@@ -1,4 +1,3 @@
import $ from 'jquery';
import {queryElemSiblings} from '../../utils/dom.ts';
export function initFomanticTab() {
@@ -1,5 +1,3 @@
import $ from 'jquery';
export function initFomanticTransition() {
const transitionNopBehaviors = new Set([
'clear queue', 'stop', 'stop all', 'destroy',
+17
View File
@@ -0,0 +1,17 @@
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
window.MonacoEnvironment = {
getWorker(_: string, label: string) {
if (label === 'json') return new jsonWorker();
if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker();
if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker();
if (label === 'typescript' || label === 'javascript') return new tsWorker();
return new editorWorker();
},
};
export * from 'monaco-editor';
+1 -1
View File
@@ -3,7 +3,7 @@ import type SortableType from 'sortablejs';
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise<SortableType> {
// type reassigned because typescript derives the wrong type from this import
const {Sortable} = (await import(/* webpackChunkName: "sortablejs" */'sortablejs') as unknown as {Sortable: typeof SortableType});
const {Sortable} = (await import('sortablejs') as unknown as {Sortable: typeof SortableType});
return new Sortable(el, {
animation: 150,
+2 -2
View File
@@ -1,11 +1,11 @@
const {appSubUrl, assetVersionEncoded} = window.config;
const {appSubUrl, sharedWorkerUri} = window.config;
export class UserEventsSharedWorker {
sharedWorker: SharedWorker;
// options can be either a string (the debug name of the worker) or an object of type WorkerOptions
constructor(options?: string | WorkerOptions) {
const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options);
const worker = new SharedWorker(sharedWorkerUri, options);
this.sharedWorker = worker;
worker.addEventListener('error', (event) => {
console.error('worker error', event);
+1 -1
View File
@@ -47,7 +47,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin {
async render(container: HTMLElement, fileUrl: string): Promise<void> {
// TODO: height and/or max-height?
const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer');
const OV = await import('online-3d-viewer');
const viewer = new OV.EmbeddedViewer(container, {
backgroundColor: new OV.RGBAColor(59, 68, 76, 0),
defaultColor: new OV.RGBColor(65, 131, 196),
+1 -1
View File
@@ -9,7 +9,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin {
},
async render(container: HTMLElement, fileUrl: string): Promise<void> {
const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
const PDFObject = await import('pdfobject');
// TODO: the PDFObject library does not support dynamic height adjustment,
container.style.height = `${window.innerHeight - 100}px`;
if (!PDFObject.default.embed(fileUrl, container)) {
+1
View File
@@ -1,3 +1,4 @@
import '../../css/standalone/devtest.css';
import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts';
type LevelMap = Record<string, (message: string) => Toast | null>;
@@ -11,6 +11,8 @@ RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px so
*/
import '../../css/standalone/external-render-iframe.css';
function mainExternalRenderIframe() {
const u = new URL(window.location.href);
const iframeId = u.searchParams.get('gitea-iframe-id');
+2 -2
View File
@@ -1,7 +1,7 @@
import '../../css/standalone/swagger.css';
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js';
import 'swagger-ui-dist/swagger-ui.css';
import {load as loadYaml} from 'js-yaml';
import {GET} from '../modules/fetch.ts';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => document.documentElement.classList.toggle('dark-mode', prefersDark.matches);
@@ -13,7 +13,7 @@ window.addEventListener('load', async () => {
const url = elSwaggerUi.getAttribute('data-source')!;
let spec: any;
if (url) {
const res = await GET(url);
const res = await fetch(url); // eslint-disable-line no-restricted-globals
spec = await res.json();
} else {
const elSpecContent = elSwaggerUi.querySelector<HTMLTextAreaElement>('.swagger-spec-content')!;
+1 -1
View File
@@ -2,7 +2,7 @@
// even if backend is in testing mode, frontend could be complied in production mode
// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files)
export function isInFrontendUnitTest() {
return import.meta.env.TEST === 'true';
return import.meta.env.MODE === 'test';
}
/** strip common indentation from a string and trim it */
+14 -2
View File
@@ -1,10 +1,20 @@
window.__webpack_public_path__ = '';
// Stub APIs not implemented by happy-dom but needed by dependencies
// XPathEvaluator is used by htmx at module evaluation time
// TODO: Remove after https://github.com/capricorn86/happy-dom/pull/2103 is released
if (!globalThis.XPathEvaluator) {
globalThis.XPathEvaluator = class {
createExpression() { return {evaluate: () => ({iterateNext: () => null})} }
} as any;
}
// Dynamic import so polyfills above are applied before htmx evaluates
await import('./globals.ts');
window.config = {
appUrl: 'http://localhost:3000/',
appSubUrl: '',
assetVersionEncoded: '',
assetUrlPrefix: '',
sharedWorkerUri: '',
runModeIsProd: true,
customEmojis: {},
pageData: {},
@@ -13,3 +23,5 @@ window.config = {
mermaidMaxSourceCharacters: 5000,
i18n: {},
};
export {}; // mark as module for top-level await
+1 -1
View File
@@ -8,4 +8,4 @@ https://developer.mozilla.org/en-US/docs/Web/Web_Components
* These components are loaded in `<head>` (before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much.
* Do not import `svg.js` into a web component because that file is currently not tree-shakeable, import svg files individually insteat.
* All our components must be added to `webpack.config.js` so they work correctly in Vue.
* All our components must be added to `vite.config.ts` so they work correctly in Vue.
+73 -50
View File
@@ -1,11 +1,10 @@
import {throttle} from 'throttle-debounce';
import {createTippy} from '../modules/tippy.ts';
import {addDelegatedEventListener, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
window.customElements.define('overflow-menu', class extends HTMLElement {
tippyContent: HTMLDivElement;
tippyItems: Array<HTMLElement>;
popup: HTMLDivElement;
overflowItems: Array<HTMLElement>;
button: HTMLButtonElement | null;
menuItemsEl: HTMLElement;
resizeObserver: ResizeObserver;
@@ -13,18 +12,42 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
lastWidth: number;
updateButtonActivationState() {
if (!this.button || !this.tippyContent) return;
this.button.classList.toggle('active', Boolean(this.tippyContent.querySelector('.item.active')));
if (!this.button || !this.popup) return;
this.button.classList.toggle('active', Boolean(this.popup.querySelector('.item.active')));
}
showPopup() {
if (!this.popup || this.popup.style.display !== 'none') return;
this.popup.style.display = '';
this.button!.setAttribute('aria-expanded', 'true');
setTimeout(() => this.popup.focus(), 0);
document.addEventListener('click', this.onClickOutside, true);
}
hidePopup() {
if (!this.popup || this.popup.style.display === 'none') return;
this.popup.style.display = 'none';
this.button?.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', this.onClickOutside, true);
}
onClickOutside = (e: Event) => {
if (!this.popup?.contains(e.target as Node) && !this.button?.contains(e.target as Node)) {
this.hidePopup();
}
};
updateItems = throttle(100, () => {
if (!this.tippyContent) {
if (!this.popup) {
const div = document.createElement('div');
div.classList.add('overflow-menu-popup');
div.setAttribute('role', 'menu');
div.tabIndex = -1; // for initial focus, programmatic focus only
div.style.display = 'none';
div.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (e.key === 'Tab') {
const items = this.tippyContent.querySelectorAll<HTMLElement>('[role="menuitem"]');
const items = this.popup.querySelectorAll<HTMLElement>('[role="menuitem"]');
if (e.shiftKey) {
if (document.activeElement === items[0]) {
e.preventDefault();
@@ -39,7 +62,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this.button?._tippy.hide();
this.hidePopup();
this.button?.focus();
} else if (e.key === ' ' || e.code === 'Enter') {
if (document.activeElement?.matches('[role="menuitem"]')) {
@@ -48,20 +71,20 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
(document.activeElement as HTMLElement).click();
}
} else if (e.key === 'ArrowDown') {
if (document.activeElement?.matches('.tippy-target')) {
if (document.activeElement === this.popup) {
e.preventDefault();
e.stopPropagation();
document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:first-of-type')?.focus();
this.popup.querySelector<HTMLElement>('[role="menuitem"]:first-of-type')?.focus();
} else if (document.activeElement?.matches('[role="menuitem"]')) {
e.preventDefault();
e.stopPropagation();
(document.activeElement.nextElementSibling as HTMLElement)?.focus();
}
} else if (e.key === 'ArrowUp') {
if (document.activeElement?.matches('.tippy-target')) {
if (document.activeElement === this.popup) {
e.preventDefault();
e.stopPropagation();
document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:last-of-type')?.focus();
this.popup.querySelector<HTMLElement>('[role="menuitem"]:last-of-type')?.focus();
} else if (document.activeElement?.matches('[role="menuitem"]')) {
e.preventDefault();
e.stopPropagation();
@@ -69,16 +92,15 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
}
}
});
div.classList.add('tippy-target');
this.handleItemClick(div, '.tippy-target > .item');
this.tippyContent = div;
} // end if: no tippyContent and create a new one
this.handleItemClick(div, '.overflow-menu-popup > .item');
this.popup = div;
} // end if: no popup and create a new one
const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space');
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
// move items in tippy back into the menu items for subsequent measurement
for (const item of this.tippyItems || []) {
// move items in popup back into the menu items for subsequent measurement
for (const item of this.overflowItems || []) {
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
this.menuItemsEl.append(item);
} else {
@@ -90,7 +112,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
// flex space and overflow menu are excluded from measurement
itemFlexSpace?.style.setProperty('display', 'none', 'important');
itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important');
this.tippyItems = [];
this.overflowItems = [];
const menuRight = this.offsetLeft + this.offsetWidth;
const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space');
let afterFlexSpace = false;
@@ -102,64 +124,64 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
const itemRight = item.offsetLeft + item.offsetWidth;
if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
const onlyLastItem = idx === menuItems.length - 1 && this.tippyItems.length === 0;
const onlyLastItem = idx === menuItems.length - 1 && this.overflowItems.length === 0;
const lastItemFit = onlyLastItem && menuRight - itemRight > 0;
const moveToPopup = !onlyLastItem || !lastItemFit;
if (moveToPopup) this.tippyItems.push(item);
if (moveToPopup) this.overflowItems.push(item);
}
}
itemFlexSpace?.style.removeProperty('display');
itemOverFlowMenuButton?.style.removeProperty('display');
// if there are no overflown items, remove any previously created button
if (!this.tippyItems?.length) {
const btn = this.querySelector('.overflow-menu-button');
btn?._tippy?.destroy();
btn?.remove();
if (!this.overflowItems?.length) {
this.hidePopup();
this.button?.remove();
this.popup?.remove();
this.button = null;
return;
}
// remove aria role from items that moved from tippy to menu
// remove aria role from items that moved from popup to menu
for (const item of menuItems) {
if (!this.tippyItems.includes(item)) {
if (!this.overflowItems.includes(item)) {
item.removeAttribute('role');
}
}
// move all items that overflow into tippy
for (const item of this.tippyItems) {
// move all items that overflow into popup
for (const item of this.overflowItems) {
item.setAttribute('role', 'menuitem');
this.tippyContent.append(item);
this.popup.append(item);
}
// update existing tippy
if (this.button?._tippy) {
this.button._tippy.setContent(this.tippyContent);
// update existing popup
if (this.button) {
this.updateButtonActivationState();
return;
}
// create button initially
// create button and attach popup
const popupId = generateElemId('overflow-popup-');
this.popup.id = popupId;
this.button = document.createElement('button');
this.button.classList.add('overflow-menu-button');
this.button.setAttribute('aria-label', window.config.i18n.more_items);
this.button.setAttribute('aria-haspopup', 'true');
this.button.setAttribute('aria-expanded', 'false');
this.button.setAttribute('aria-controls', popupId);
this.button.innerHTML = octiconKebabHorizontal;
this.append(this.button);
createTippy(this.button, {
trigger: 'click',
hideOnClick: true,
interactive: true,
placement: 'bottom-end',
role: 'menu',
theme: 'menu',
content: this.tippyContent,
onShow: () => { // FIXME: onShown doesn't work (never be called)
setTimeout(() => {
this.tippyContent.focus();
}, 0);
},
this.button.addEventListener('click', (e) => {
e.stopPropagation();
if (this.popup.style.display === 'none') {
this.showPopup();
} else {
this.hidePopup();
}
});
this.append(this.button);
this.append(this.popup);
this.updateButtonActivationState();
});
@@ -202,7 +224,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
handleItemClick(el: Element, selector: string) {
addDelegatedEventListener(el, 'click', selector, () => {
this.button?._tippy?.hide();
this.hidePopup();
this.updateButtonActivationState();
});
}
@@ -239,5 +261,6 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
disconnectedCallback() {
this.mutationObserver?.disconnect();
this.resizeObserver?.disconnect();
document.removeEventListener('click', this.onClickOutside, true);
}
});