diff options
Diffstat (limited to 'web_src')
135 files changed, 2189 insertions, 1688 deletions
diff --git a/web_src/css/actions.css b/web_src/css/actions.css index 0ab09f537a..c43ebe21a0 100644 --- a/web_src/css/actions.css +++ b/web_src/css/actions.css @@ -6,14 +6,6 @@ overflow-x: auto; } -.runner-container .runner-ops > a { - margin-left: 0.5em; -} - -.runner-container .runner-ops-delete { - color: var(--color-red-light); -} - .runner-container .runner-new-text { color: var(--color-white); } @@ -67,6 +59,7 @@ .run-list-ref { display: inline-block !important; + max-width: 105px; } @media (max-width: 767.98px) { diff --git a/web_src/css/base.css b/web_src/css/base.css index 47b4f44a66..529ddd5386 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -2,7 +2,10 @@ /* fonts */ --fonts-proportional: -apple-system, "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial; --fonts-monospace: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, var(--fonts-emoji); - --fonts-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla"; + /* GitHub explicitly sets font names like: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla"; + Actually "Twemoji Mozilla" emoji font is widely used by browsers like Firefox, Pale Moon, and it is more likely up-to-dated than the system emoji font. + So not setting emoji font seems to be the best choice, here we just use a non-existing dummy font name and let browsers choose. */ + --fonts-emoji: -emoji-fallback; /* font weights - use between 400 and 600 for general purposes. Avoid 700 as it is perceived too bold */ --font-weight-light: 300; --font-weight-normal: 400; @@ -26,6 +29,16 @@ --checkbox-size: 15px; /* height and width of checkbox and radio inputs */ --page-spacing: 16px; /* space between page elements */ --page-margin-x: 32px; /* minimum space on left and right side of page */ + --page-space-bottom: 64px; /* space between last page element and footer */ + + /* z-index */ + --z-index-modal: 1001; /* modal dialog, hard-coded from Fomantic modal.css */ + --z-index-toast: 1002; /* should be larger than modal */ + + --font-size-label: 12px; /* font size of individual labels */ + + --gap-inline: 0.25rem; /* gap for inline texts and elements, for example: the spaces for sentence with labels, button text, etc */ + --gap-block: 0.25rem; /* gap for element blocks, for example: spaces between buttons, menu image & title, header icon & title etc */ } @media (min-width: 768px) and (max-width: 1200px) { @@ -224,6 +237,7 @@ progress::-moz-progress-bar { } .unselectable, +.btn, .button, .lines-num, .lines-commit, @@ -313,6 +327,16 @@ a.label, background: var(--color-hover); } +.empty-placeholder { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 40px; + padding-bottom: 40px; + text-align: center; + text-wrap: balance; +} + .inline-code-block { padding: 2px 4px; border-radius: .24em; @@ -347,6 +371,7 @@ a.label, .ui.dropdown .menu > .item { color: var(--color-text); + line-height: var(--line-height-default); } .ui.dropdown .menu > .item:hover { @@ -474,7 +499,7 @@ img.ui.avatar, .full.height { flex-grow: 1; - padding-bottom: 80px; + padding-bottom: var(--page-space-bottom); } .status-page-error { @@ -646,10 +671,11 @@ img.ui.avatar, border: 1px solid; } -.ui.floating.dropdown .overflow.menu .scrolling.menu.items { +.ui.dropdown .menu.context-user-switch .scrolling.menu { border-radius: 0 !important; box-shadow: none !important; border-bottom: 1px solid var(--color-secondary); + max-width: 80vw; } .user-menu > .item { @@ -745,6 +771,14 @@ overflow-menu .overflow-menu-button { padding: 0; } +/* match the styles of ".ui.secondary.pointing.menu .active.item" */ +overflow-menu.ui.secondary.pointing.menu .overflow-menu-button.active { + padding: 2px 0 0; + border-bottom: 2px solid currentcolor; + background-color: transparent; + font-weight: var(--font-weight-medium); +} + overflow-menu .overflow-menu-button:hover { color: var(--color-text-dark); } @@ -800,10 +834,6 @@ overflow-menu .ui.label { display: block; } -.code-view .lines-num span::after { - cursor: pointer; -} - .lines-type-marker { vertical-align: top; white-space: nowrap; @@ -840,43 +870,16 @@ overflow-menu .ui.label { .lines-escape { width: 0; white-space: nowrap; + padding: 0; } .lines-code { padding-left: 5px; } -.file-view tr.active { - color: inherit !important; - background: inherit !important; -} - -.file-view tr.active .lines-num, -.file-view tr.active .lines-code { - background: var(--color-highlight-bg) !important; -} - -.file-view tr.active:last-of-type .lines-code { - border-bottom-right-radius: var(--border-radius); -} - -.file-view tr.active .lines-num { - position: relative; -} - -.file-view tr.active .lines-num::before { - content: ""; - position: absolute; - left: 0; - width: 2px; - height: 100%; - background: var(--color-highlight-fg); -} - .code-inner { font: 12px var(--fonts-monospace); white-space: pre-wrap; - word-break: break-all; overflow-wrap: anywhere; line-height: inherit; /* needed for inline code preview in markup */ } @@ -924,12 +927,12 @@ overflow-menu .ui.label { margin-right: 4px; } -.top-line-blame { +tr.top-line-blame { border-top: 1px solid var(--color-secondary); } -.code-view tr.top-line-blame:first-of-type { - border-top: none; +tr.top-line-blame:first-of-type { + border-top: none; /* merge code lines belonging to the same commit into one block */ } .lines-code .bottom-line, @@ -937,15 +940,6 @@ overflow-menu .ui.label { border-bottom: 1px solid var(--color-secondary); } -.code-view { - background: var(--color-code-bg); - border-radius: var(--border-radius); -} - -.code-view table { - width: 100%; -} - .migrate .svg.gitea-git { color: var(--color-git); } @@ -989,14 +983,7 @@ table th[data-sortt-desc] .svg { box-shadow: 0 0 0 1px var(--color-secondary) inset; } -.emoji { - font-size: 1.25em; - line-height: var(--line-height-default); - font-style: normal !important; - font-weight: var(--font-weight-normal) !important; - vertical-align: -0.075em; -} - +/* for "image" emojis like ":git:" ":gitea:" and ":github:" (see CUSTOM_EMOJIS config option) */ .emoji img { border-width: 0 !important; margin: 0 !important; @@ -1035,26 +1022,13 @@ table th[data-sortt-desc] .svg { text-align: left; } -.truncated-item-container { - display: flex !important; - align-items: center; -} - -.ellipsis-button { - padding: 0 5px 8px !important; - display: inline-block !important; - font-weight: var(--font-weight-semibold) !important; - line-height: 6px !important; - vertical-align: middle !important; -} - -.truncated-item-name { - line-height: 2; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-top: -0.5em; - margin-bottom: -0.5em; +.ui.button.ellipsis-button { + padding: 0 5px 8px; + display: inline-block; + font-weight: var(--font-weight-semibold); + line-height: 8px; + vertical-align: middle; + min-height: 0; } .precolors { @@ -1124,7 +1098,7 @@ table th[data-sortt-desc] .svg { .flex-text-inline { display: inline-flex; align-items: center; - gap: .25rem; + gap: var(--gap-inline); vertical-align: middle; min-width: 0; /* make ellipsis work */ } @@ -1147,20 +1121,27 @@ table th[data-sortt-desc] .svg { } .ui.list.flex-items-block > .item, +.ui.form .field > label.flex-text-block, /* override fomantic "block" style */ .flex-items-block > .item, .flex-text-block { display: flex; align-items: center; - gap: .5rem; + gap: var(--gap-block); min-width: 0; } +.ui.dropdown > .ui.button, +.flex-text-block > .ui.button, +.flex-text-inline > .ui.button { + margin: 0; /* fomantic buttons have default margin, when we use them in a flex container with gap, we do not need these margins */ +} + /* to override Fomantic's default display: block for ".menu .item", and use a slightly larger gap for menu item content the "!important" is necessary to override Fomantic UI menu item styles, meanwhile we should keep the "hidden" items still hidden */ .ui.dropdown .menu.flex-items-menu > .item:not(.hidden, .filtered, .tw-hidden) { display: flex !important; align-items: center; - gap: .5rem; + gap: var(--gap-block); min-width: 0; } .ui.dropdown .menu.flex-items-menu > .item img, @@ -1168,25 +1149,33 @@ the "!important" is necessary to override Fomantic UI menu item styles, meanwhil margin: 0; /* use gap, but not margin */ } -.ui.dropdown.ellipsis-items-nowrap > .text { +.ui.dropdown.ellipsis-text-items { + /* reset y padding and use the line-height below instead, to avoid the "overflow: hidden" clips the larger image in the "text" element */ + padding-top: 0; + padding-bottom: 0; +} + +.ui.dropdown.ellipsis-text-items > .text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + line-height: 2.71; /* matches fomantic dropdown's default min-height */ } -.ellipsis-items-nowrap > .item, -.ui.dropdown.ellipsis-items-nowrap .menu > .item { +.ui.dropdown.ellipsis-text-items .menu > .item { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } -.ui.dropdown.text-flex-grow { - display: flex; +.svg.octicon-file-directory-fill, +.svg.octicon-file-directory-open-fill, +.svg.octicon-file-submodule { + color: var(--color-primary); } -.ui.dropdown.text-flex-grow > .text { - display: flex; - flex-grow: 1; - justify-content: space-between; +.svg.octicon-file, +.svg.octicon-file-symlink-file, +.svg.octicon-file-directory-symlink { + color: var(--color-secondary-dark-7); } diff --git a/web_src/css/editor/combomarkdowneditor.css b/web_src/css/editor/combomarkdowneditor.css index 835286b795..046010c6c8 100644 --- a/web_src/css/editor/combomarkdowneditor.css +++ b/web_src/css/editor/combomarkdowneditor.css @@ -100,67 +100,3 @@ border-bottom: 1px solid var(--color-secondary); padding-bottom: 1rem; } - -text-expander { - display: block; - position: relative; -} - -text-expander .suggestions { - position: absolute; - min-width: 180px; - padding: 0; - margin-top: 24px; - list-style: none; - background: var(--color-box-body); - border-radius: var(--border-radius); - border: 1px solid var(--color-secondary); - box-shadow: 0 .5rem 1rem var(--color-shadow); - z-index: 100; /* needs to be > 20 to be on top of dropzone's .dz-details */ -} - -text-expander .suggestions li { - display: flex; - align-items: center; - cursor: pointer; - padding: 4px 8px; - font-weight: var(--font-weight-medium); -} - -text-expander .suggestions li + li { - border-top: 1px solid var(--color-secondary-alpha-40); -} - -text-expander .suggestions li:first-child { - border-radius: var(--border-radius) var(--border-radius) 0 0; -} - -text-expander .suggestions li:last-child { - border-radius: 0 0 var(--border-radius) var(--border-radius); -} - -text-expander .suggestions li:only-child { - border-radius: var(--border-radius); -} - -text-expander .suggestions li:hover { - background: var(--color-hover); -} - -text-expander .suggestions .fullname { - font-weight: var(--font-weight-normal); - margin-left: 4px; - color: var(--color-text-light-1); -} - -text-expander .suggestions li[aria-selected="true"], -text-expander .suggestions li[aria-selected="true"] span { - background: var(--color-primary); - color: var(--color-primary-contrast); -} - -text-expander .suggestions img { - width: 24px; - height: 24px; - margin-right: 8px; -} diff --git a/web_src/css/features/expander.css b/web_src/css/features/expander.css new file mode 100644 index 0000000000..f560b2a9fd --- /dev/null +++ b/web_src/css/features/expander.css @@ -0,0 +1,96 @@ +text-expander .suggestions, +.tribute-container { + position: absolute; + max-height: min(300px, 95vh); + max-width: min(500px, 95vw); + overflow-x: hidden; + overflow-y: auto; + white-space: nowrap; + background: var(--color-menu); + box-shadow: 0 6px 18px var(--color-shadow); + border-radius: var(--border-radius); + border: 1px solid var(--color-secondary); + z-index: 100; /* needs to be > 20 to be on top of dropzone's .dz-details */ +} + +text-expander { + display: block; + position: relative; +} + +text-expander .suggestions { + padding: 0; + margin-top: 24px; + list-style: none; +} + +text-expander .suggestions li, +.tribute-item { + display: flex; + align-items: center; + cursor: pointer; + gap: 6px; + font-weight: var(--font-weight-medium); +} + +text-expander .suggestions li, +.tribute-container li { + padding: 3px 6px; +} + +text-expander .suggestions li + li, +.tribute-container li + li { + border-top: 1px solid var(--color-secondary); +} + +text-expander .suggestions li:first-child { + border-radius: var(--border-radius) var(--border-radius) 0 0; +} + +text-expander .suggestions li:last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); +} + +text-expander .suggestions li:only-child { + border-radius: var(--border-radius); +} + +text-expander .suggestions .fullname, +.tribute-container li .fullname { + font-weight: var(--font-weight-normal); + color: var(--color-text-light-1); + overflow: hidden; + text-overflow: ellipsis; +} + +text-expander .suggestions li:hover, +text-expander .suggestions li:hover *, +text-expander .suggestions li[aria-selected="true"], +text-expander .suggestions li[aria-selected="true"] *, +.tribute-container li.highlight, +.tribute-container li.highlight * { + background: var(--color-primary); + color: var(--color-primary-contrast); +} + +text-expander .suggestions img, +.tribute-item img { + width: 21px; + height: 21px; + object-fit: contain; + aspect-ratio: 1; +} + +.tribute-container { + display: block; +} + +.tribute-container ul { + margin: 0; + padding: 0; + list-style: none; +} + +.tribute-container li.no-match { + cursor: default; +} diff --git a/web_src/css/features/gitgraph.css b/web_src/css/features/gitgraph.css index 1ed541a695..865c82e003 100644 --- a/web_src/css/features/gitgraph.css +++ b/web_src/css/features/gitgraph.css @@ -116,6 +116,7 @@ max-width: 200px; overflow: hidden; text-overflow: ellipsis; + min-height: 0; } #git-graph-container #graph-raw-list { diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css index 8763d3684e..7fd5150970 100644 --- a/web_src/css/features/projects.css +++ b/web_src/css/features/projects.css @@ -1,43 +1,35 @@ -.board { +#project-board { display: flex; + align-items: stretch; flex-direction: row; flex-wrap: nowrap; - overflow-x: auto; - overflow-y: clip; - align-items: stretch; + overflow: auto; margin: 0 0.5em; + min-height: max(calc(100vh - 400px), 300px); + max-height: calc(100vh - 120px); } -.project-toolbar-right .filter.menu { - flex-direction: row; +.project-header { + padding: 0.5em 0; flex-wrap: wrap; } -@media (max-width: 767.98px) { - .project-toolbar-right .dropdown .menu { - left: auto !important; - right: auto !important; - } +.project-header h2 { + white-space: nowrap; + margin: 0; } .project-column { - background-color: var(--color-project-column-bg) !important; - border: 1px solid var(--color-secondary) !important; - border-radius: var(--border-radius); - margin: 0 0.5rem !important; - padding: 0.5rem !important; - width: 320px; - height: initial; - min-height: max(calc(100vh - 400px), 300px); flex: 0 0 auto; - overflow: visible; display: flex; flex-direction: column; - cursor: default; -} - -.project-column .issue-card { - color: var(--color-text); + background-color: var(--color-project-column-bg); + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius); + margin: 0 0.5rem; + padding: 0.5rem; + width: 320px; + overflow: visible; } .project-column-header { @@ -51,16 +43,15 @@ color: inherit; } -.project-column > .cards { +.project-column > .ui.cards { flex: 1; display: flex; - align-content: baseline; - margin: 0 !important; - padding: 0 !important; - flex-wrap: nowrap !important; + flex-wrap: nowrap; flex-direction: column; - overflow-x: clip; + overflow: clip auto; gap: .25rem; + margin: 0; + padding: 0; } .project-column > .divider { @@ -110,3 +101,21 @@ .card-ghost * { opacity: 0; } + +.fullscreen.projects-view { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +/* Hide project-description in full-screen due to its variable height. No need to show it for better space use. */ +.fullscreen.projects-view .project-description { + display: none; +} + +.fullscreen.projects-view #project-board { + flex: 1; + max-height: unset; + padding-bottom: 0.5em; +} diff --git a/web_src/css/features/tribute.css b/web_src/css/features/tribute.css deleted file mode 100644 index 99a026b9bc..0000000000 --- a/web_src/css/features/tribute.css +++ /dev/null @@ -1,32 +0,0 @@ -@import "tributejs/dist/tribute.css"; - -.tribute-container { - box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.25); - border-radius: var(--border-radius); -} - -.tribute-container ul { - margin-top: 0 !important; - background: var(--color-body) !important; -} - -.tribute-container li { - padding: 3px 0.5rem !important; -} - -.tribute-container li span.fullname { - font-weight: var(--font-weight-normal); - font-size: 0.8rem; -} - -.tribute-container li.highlight, -.tribute-container li:hover { - background: var(--color-primary) !important; - color: var(--color-primary-contrast) !important; -} - -.tribute-item { - display: flex; - align-items: center; - gap: 6px; -} diff --git a/web_src/css/form.css b/web_src/css/form.css index cf8fe96bea..c51eba1bc9 100644 --- a/web_src/css/form.css +++ b/web_src/css/form.css @@ -220,6 +220,7 @@ textarea:focus, color: var(--color-secondary-dark-5); padding-bottom: 0.6em; display: inline-block; + text-wrap: balance; } .m-captcha-style { diff --git a/web_src/css/home.css b/web_src/css/home.css index 77d2ecf92b..195d1f5d96 100644 --- a/web_src/css/home.css +++ b/web_src/css/home.css @@ -21,7 +21,7 @@ } .home .hero .svg { - color: var(--color-green); + color: var(--color-logo); height: 40px; width: 50px; vertical-align: bottom; @@ -40,7 +40,7 @@ } .home a { - color: var(--color-green); + color: var(--color-logo); } .page-footer { diff --git a/web_src/css/index.css b/web_src/css/index.css index 84795d6d27..291cd04b2b 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -39,7 +39,7 @@ @import "./features/imagediff.css"; @import "./features/codeeditor.css"; @import "./features/projects.css"; -@import "./features/tribute.css"; +@import "./features/expander.css"; @import "./features/cropper.css"; @import "./features/console.css"; @@ -62,7 +62,7 @@ @import "./repo/issue-label.css"; @import "./repo/issue-list.css"; @import "./repo/list-header.css"; -@import "./repo/linebutton.css"; +@import "./repo/file-view.css"; @import "./repo/wiki.css"; @import "./repo/header.css"; @import "./repo/home.css"; @@ -70,6 +70,7 @@ @import "./repo/reactions.css"; @import "./repo/clone.css"; @import "./repo/commit-sign.css"; +@import "./repo/packages.css"; @import "./editor/fileeditor.css"; @import "./editor/combomarkdowneditor.css"; diff --git a/web_src/css/markup/codecopy.css b/web_src/css/markup/codecopy.css index e3017ae962..5a7b9955e7 100644 --- a/web_src/css/markup/codecopy.css +++ b/web_src/css/markup/codecopy.css @@ -1,8 +1,3 @@ -.markup .code-block, -.markup .mermaid-block { - position: relative; -} - .markup .code-copy { position: absolute; top: 8px; @@ -28,8 +23,8 @@ background: var(--color-secondary-dark-1) !important; } -.markup .code-block:hover .code-copy, -.markup .mermaid-block:hover .code-copy { +.markup .code-block-container:hover .code-copy, +.markup .code-block:hover .code-copy { visibility: visible; animation: fadein 0.2s both; } diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index 865ac0536a..c6a89edf25 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -5,10 +5,6 @@ overflow-wrap: break-word; } -.conversation-holder .markup { - overflow-wrap: anywhere; /* prevent overflow in code comments. TODO: properly restrict .conversation-holder width and remove this */ -} - .markup > *:first-child { margin-top: 0 !important; } @@ -138,6 +134,13 @@ margin-bottom: 16px; } +/* override p:last-child from base.css. +Fomantic assumes that <p>/<hX> elements only have margins between elements, but not for the first's top or last's bottom. +In markup content, we always use bottom margin for all elements */ +.markup p:last-child { + margin-bottom: 16px; +} + .markup hr { height: 4px; padding: 0; @@ -313,10 +316,18 @@ box-sizing: initial; } +.file-view.markup { + padding: 1em 2em; +} + +.file-view.markup:has(.file-not-rendered-prompt) { + padding: 0; /* let the file-not-rendered-prompt layout itself */ +} + /* this background ensures images can break <hr>. We can only do this on cases where the background is known and not transparent. */ -.markup.file-view img, -.markup.file-view video, +.file-view.markup img, +.file-view.markup video, .comment-body .markup img, /* regular comment */ .comment-body .markup video, .comment-content .markup img, /* code comment */ @@ -336,11 +347,6 @@ padding-right: 28px; } -.markup .emoji { - max-width: none; - vertical-align: text-top; -} - .markup span.frame { display: block; overflow: hidden; @@ -452,14 +458,25 @@ } .markup pre > code { - padding: 0; - margin: 0; font-size: 100%; +} + +.markup .code-block, +.markup .code-block-container { + position: relative; +} + +.markup .code-block-container.code-overflow-wrap pre > code { white-space: pre-wrap; - word-break: break-all; - overflow-wrap: break-word; - background: transparent; - border: 0; +} + +.markup .code-block-container.code-overflow-scroll pre { + overflow-x: auto; +} + +.markup .code-block-container.code-overflow-scroll pre > code { + white-space: pre; + overflow-wrap: normal; } .markup .highlight { @@ -480,16 +497,11 @@ word-break: normal; } -.markup pre { - word-wrap: normal; -} - .markup pre code, .markup pre tt { display: inline; padding: 0; line-height: inherit; - word-wrap: normal; background-color: transparent; border: 0; } @@ -520,18 +532,16 @@ padding-left: 2em; } -.file-revisions-btn { - display: block; - float: left; - margin-bottom: 2px !important; - padding: 11px !important; - margin-right: 10px !important; +.markup details.frontmatter-content summary { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + margin-bottom: 0.25em; } -.file-revisions-btn i { - -webkit-touch-callout: none; - -webkit-user-select: none; - user-select: none; +.markup details.frontmatter-content svg { + vertical-align: middle; + margin: 0 0.25em; } .markup-content-iframe { diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css index 481e997d4f..deaaf83680 100644 --- a/web_src/css/modules/animations.css +++ b/web_src/css/modules/animations.css @@ -52,8 +52,7 @@ form.single-button-form.is-loading .button { } .markup pre.is-loading, -.editor-loading.is-loading, -.pdf-content.is-loading { +.editor-loading.is-loading { height: var(--height-loading); } @@ -116,3 +115,15 @@ code.language-math.is-loading::after { animation-duration: 100ms; animation-timing-function: ease-in-out; } + +/* FIXME: `octicon-sync` is counterclockwise, so this animation is also counterclockwise, it looks somewhat strange. +Ideally in the future we should use a better image for clockwise animation. */ +.circular-spin { + animation: circular-spin-keyframes 1s linear infinite; +} + +@keyframes circular-spin-keyframes { + 100% { + transform: rotate(-360deg); + } +} diff --git a/web_src/css/modules/breadcrumb.css b/web_src/css/modules/breadcrumb.css index ca488c2150..77e31ef627 100644 --- a/web_src/css/modules/breadcrumb.css +++ b/web_src/css/modules/breadcrumb.css @@ -1,14 +1,10 @@ .breadcrumb { display: flex; - flex-wrap: wrap; align-items: center; gap: 3px; + overflow-wrap: anywhere; } .breadcrumb .breadcrumb-divider { color: var(--color-text-light-2); } - -.breadcrumb > * { - display: inline; -} diff --git a/web_src/css/modules/button.css b/web_src/css/modules/button.css index c4addd05f0..b105bb5de2 100644 --- a/web_src/css/modules/button.css +++ b/web_src/css/modules/button.css @@ -1,20 +1,15 @@ -/* based on Fomantic UI checkbox module, with just the parts extracted that we use. If you find any - unused rules here after refactoring, please remove them. */ - .ui.button { cursor: pointer; - display: inline-block; - min-height: 1em; + display: inline-flex; outline: none; - vertical-align: baseline; font-family: var(--fonts-regular); margin: 0 0.25em 0 0; - padding: 0.78571429em 1.5em; font-weight: var(--font-weight-normal); + font-size: 1rem; text-align: center; text-decoration: none; line-height: 1; - border-radius: 0.28571429rem; + border-radius: var(--border-radius); user-select: none; -webkit-tap-highlight-color: transparent; justify-content: center; @@ -58,12 +53,13 @@ pointer-events: none !important; } +/* there is no "ui labeled icon button" support" because it is not used */ .ui.labeled.button:not(.icon) { - display: inline-flex; flex-direction: row; background: none; - padding: 0 !important; + padding: 0; border: none; + min-height: unset; } .ui.labeled.button > .button { margin: 0; @@ -102,47 +98,60 @@ margin: 0 -0.21428571em 0 0.42857143em; } +/* reference sizes (not exactly at the moment): normal: padding-x=21, height=38 ; compact: padding-x=15, height=32 */ +.ui.button { /* stylelint-disable-line no-duplicate-selectors */ + min-height: 38px; + padding: 0.57em /* around 8px */ 1.43em /* around 20px */; +} .ui.compact.buttons .button, .ui.compact.button { - padding: 0.58928571em 1.125em; + padding: 0.42em /* around 8px */ 1.07em /* around 15px */; + min-height: 32px; } .ui.compact.icon.buttons .button, .ui.compact.icon.button { - padding: 0.58928571em; -} -.ui.compact.labeled.icon.button { - padding: 0.58928571em 3.69642857em; -} -.ui.compact.labeled.icon.button > .icon { - padding: 0.58928571em 0; + padding: 0.57em /* around 8px */; } -.ui.buttons .button, -.ui.button { - font-size: 1rem; -} +/* reference size: mini: padding-x=16, height=30 ; compact: padding-x=12, height=26 */ .ui.mini.buttons .dropdown, .ui.mini.buttons .dropdown .menu > .item, .ui.mini.buttons .button, .ui.ui.ui.ui.mini.button { - font-size: 0.78571429rem; + font-size: 11px; + min-height: 30px; +} +.ui.ui.ui.ui.mini.button.compact { + min-height: 26px; } + +/* reference size: tiny: padding-x=18, height=32 ; compact: padding-x=13, height=28 */ .ui.tiny.buttons .dropdown, .ui.tiny.buttons .dropdown .menu > .item, .ui.tiny.buttons .button, .ui.ui.ui.ui.tiny.button { - font-size: 0.85714286rem; + font-size: 12px; + min-height: 32px; +} +.ui.ui.ui.ui.tiny.button.compact { + min-height: 28px; } + +/* reference size: small: padding-x=19, height=34 ; compact: padding-x=14, height=30 */ .ui.small.buttons .dropdown, .ui.small.buttons .dropdown .menu > .item, .ui.small.buttons .button, .ui.ui.ui.ui.small.button { - font-size: 0.92857143rem; + font-size: 13px; + min-height: 34px; +} +.ui.ui.ui.ui.small.button.compact { + min-height: 30px; } .ui.icon.buttons .button, .ui.icon.button:not(.compact) { - padding: 0.78571429em; + padding: 0.57em; } .ui.icon.buttons .button > .icon, .ui.icon.button > .icon { @@ -152,12 +161,12 @@ .ui.basic.buttons .button, .ui.basic.button { - border-radius: 0.28571429rem; + border-radius: var(--border-radius); background: none; } .ui.basic.buttons { border: 1px solid var(--color-secondary); - border-radius: 0.28571429rem; + border-radius: var(--border-radius); } .ui.basic.buttons .button { border-radius: 0; @@ -188,29 +197,6 @@ background: var(--color-active); } -.ui.labeled.icon.button { - position: relative; - padding-left: 4.07142857em !important; - padding-right: 1.5em !important; -} - -.ui.labeled.icon.button > .icon { - position: absolute; - top: 0; - left: 0; - height: 100%; - line-height: 1; - border-radius: 0; - border-top-left-radius: inherit; - border-bottom-left-radius: inherit; - text-align: center; - animation: none; - padding: 0.78571429em 0; - margin: 0; - width: 2.57142857em; - background: var(--color-hover); -} - .ui.button.toggle.active { background-color: var(--color-green); color: var(--color-white); @@ -366,6 +352,14 @@ a.btn:hover { color: inherit; } +.btn.tiny { + font-size: 12px; +} + +.btn.small { + font-size: 13px; +} + /* By default, Fomantic UI doesn't support "bordered" buttons group, but Gitea would like to use it. And the default buttons always have borders now (not the same as Fomantic UI's default buttons, see above). It needs some tricks to tweak the left/right borders with active state */ @@ -379,12 +373,12 @@ It needs some tricks to tweak the left/right borders with active state */ .ui.buttons .button:first-child { border-left: none; margin-left: 0; - border-top-left-radius: 0.28571429rem; - border-bottom-left-radius: 0.28571429rem; + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); } .ui.buttons .button:last-child { - border-top-right-radius: 0.28571429rem; - border-bottom-right-radius: 0.28571429rem; + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); } .ui.buttons .button:hover { @@ -414,10 +408,3 @@ It needs some tricks to tweak the left/right borders with active state */ .ui.buttons .button.active + .button { border-left: none; } - -/* apply the vertical padding of .compact to non-compact buttons when they contain a svg as they - would otherwise appear too large. Seen on "RSS Feed" button on repo releases tab. */ -.ui.small.button:not(.compact):has(.svg) { - padding-top: 0.58928571em; - padding-bottom: 0.58928571em; -} diff --git a/web_src/css/modules/dimmer.css b/web_src/css/modules/dimmer.css index 8924821370..7d1ca6171a 100644 --- a/web_src/css/modules/dimmer.css +++ b/web_src/css/modules/dimmer.css @@ -20,7 +20,7 @@ opacity: 1; } -.ui.dimmer > * { +.ui.dimmer > .ui.modal { position: static; margin-top: auto !important; margin-bottom: auto !important; diff --git a/web_src/css/modules/label.css b/web_src/css/modules/label.css index 1e42668aa1..bb6f1b512f 100644 --- a/web_src/css/modules/label.css +++ b/web_src/css/modules/label.css @@ -4,25 +4,19 @@ .ui.label { display: inline-flex; align-items: center; - gap: .25rem; + gap: var(--gap-inline); min-width: 0; - vertical-align: middle; - line-height: 1; + max-width: 100%; background: var(--color-label-bg); color: var(--color-label-text); - padding: 0.3em 0.5em; - font-size: 0.85714286rem; + padding: 2px 6px; + font-size: var(--font-size-label); font-weight: var(--font-weight-medium); border: 0 solid transparent; - border-radius: 0.28571429rem; + border-radius: var(--border-radius); white-space: nowrap; -} - -.ui.label:first-child { - margin-left: 0; -} -.ui.label:last-child { - margin-right: 0; + overflow: hidden; + text-overflow: ellipsis; } a.ui.label { @@ -292,3 +286,58 @@ a.ui.ui.ui.basic.grey.label:hover { .ui.large.label { font-size: 1rem; } + +/* To let labels break up and wrap across multiple lines (issue title, comment event), use "display: contents here" to apply parent layout. +If the labels-list itself needs some layouts, use extra classes or "tw" helpers. */ +.labels-list { + display: contents; + font-size: var(--font-size-label); /* it must match the label font size, otherwise the height mismatches */ +} + +.labels-list a { + max-width: 100%; /* for ellipsis */ +} + +.labels-list .ui.label { + min-height: 20px; + padding-top: 0; + padding-bottom: 0; +} + +.with-labels-list-inline .labels-list .ui.label + .ui.label { + margin-left: var(--gap-inline); +} + +.with-labels-list-inline .labels-list .ui.label { + line-height: var(--line-height-default); +} + +/* Scoped labels with different colors on left and right */ +.ui.label.scope-parent { + background: none !important; + padding: 0 !important; + gap: 0 !important; +} + +.ui.label.scope-parent > .ui.label { + margin: 0 !important; /* scoped label's margin is handled by the parent */ +} + +.ui.label.scope-left { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +.ui.label.scope-middle { + border-radius: 0; +} + +.ui.label.scope-right { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} + +.ui.label.archived-label { + filter: grayscale(0.5); + opacity: 0.5; +} diff --git a/web_src/css/modules/list.css b/web_src/css/modules/list.css index 73760390de..46422cb97d 100644 --- a/web_src/css/modules/list.css +++ b/web_src/css/modules/list.css @@ -5,7 +5,6 @@ list-style-type: none; margin: 1em 0; padding: 0; - font-size: 1em; } .ui.list:first-child { diff --git a/web_src/css/modules/menu.css b/web_src/css/modules/menu.css index a5efd23053..5072dcbd0e 100644 --- a/web_src/css/modules/menu.css +++ b/web_src/css/modules/menu.css @@ -1,5 +1,6 @@ .ui.menu { display: flex; + flex-shrink: 0; margin: 1rem 0; font-family: var(--fonts-regular); font-weight: var(--font-weight-normal); @@ -643,6 +644,7 @@ display: inline-flex; margin: 0; vertical-align: middle; + flex-shrink: 0; } .ui.compact.vertical.menu { display: inline-block; diff --git a/web_src/css/modules/navbar.css b/web_src/css/modules/navbar.css index b09b271ad4..ab431e3675 100644 --- a/web_src/css/modules/navbar.css +++ b/web_src/css/modules/navbar.css @@ -129,8 +129,8 @@ background: var(--color-primary); border: 2px solid var(--color-nav-bg); position: absolute; - left: 6px; - top: -9px; + left: calc(100% - 9px); + bottom: calc(100% - 9px); min-width: 17px; height: 17px; border-radius: 11px; /* (height + 2 * borderThickness) / 2 */ diff --git a/web_src/css/modules/tippy.css b/web_src/css/modules/tippy.css index 4438a31c9d..3c0d63f2fb 100644 --- a/web_src/css/modules/tippy.css +++ b/web_src/css/modules/tippy.css @@ -92,6 +92,10 @@ } .tippy-box[data-theme="menu"] .item:focus { + background: var(--color-hover); +} + +.tippy-box[data-theme="menu"] .item.active { background: var(--color-active); } diff --git a/web_src/css/modules/toast.css b/web_src/css/modules/toast.css index 1145f3b1b5..330d3b176e 100644 --- a/web_src/css/modules/toast.css +++ b/web_src/css/modules/toast.css @@ -3,7 +3,7 @@ position: fixed; opacity: 0; transition: all .2s ease; - z-index: 500; + z-index: var(--z-index-toast); border-radius: var(--border-radius); box-shadow: 0 8px 24px var(--color-shadow); display: flex; diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 87af299dad..a72709c382 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -50,23 +50,44 @@ width: 300px; } -.issue-sidebar-combo .ui.dropdown .item:not(.checked) .item-check-mark { - visibility: hidden; +.issue-content-right .ui.dropdown.full-width { + width: 100%; +} + +.issue-content-right .ui.dropdown.full-width > .fixed-text { + display: flex; + flex-grow: 1; + justify-content: space-between; } -.issue-content-right .dropdown > .menu { +.issue-content-right .ui.dropdown > .menu { max-width: 270px; min-width: 0; max-height: 500px; overflow-x: auto; } -.issue-content-right .dropdown > .menu .item-secondary-info small { +.issue-content-right .ui.dropdown > .menu .item-secondary-info small { display: block; text-overflow: ellipsis; overflow: hidden; } +.issue-content-right .ui.list { + margin: 0.5em 0; + max-width: 100%; +} + +.issue-sidebar-combo > .ui.dropdown .item:not(.checked) .item-check-mark { + visibility: hidden; +} + +.issue-content-right .ui.list.labels-list { + display: flex; + gap: var(--gap-inline); + flex-wrap: wrap; +} + @media (max-width: 767.98px) { .issue-content-left, .issue-content-right { @@ -129,11 +150,6 @@ td .commit-summary { } } -.repo-path { - display: flex; - overflow-wrap: anywhere; -} - .repository.file.list .non-diff-file-content .header .icon { font-size: 1em; } @@ -167,42 +183,6 @@ td .commit-summary { cursor: default; } -.view-raw { - display: flex; - justify-content: center; - align-items: center; -} - -.view-raw > * { - max-width: 100%; -} - -.view-raw audio, -.view-raw video, -.view-raw img { - margin: 1rem 0; - border-radius: 0; - object-fit: contain; -} - -.view-raw img[src$=".svg" i] { - max-height: 600px !important; - max-width: 600px !important; -} - -.pdf-content { - width: 100%; - height: 600px; - border: none !important; - display: flex; - align-items: center; - justify-content: center; -} - -.pdf-content .pdf-fallback-button { - margin: 50px auto; -} - .repository.file.list .non-diff-file-content .plain-text { padding: 1em 2em; } @@ -225,10 +205,6 @@ td .commit-summary { padding: 0 !important; } -.non-diff-file-content .pdfobject { - border-radius: 0 0 var(--border-radius) var(--border-radius); -} - .repo-editor-header { width: 100%; } @@ -476,14 +452,6 @@ td .commit-summary { margin-right: 5px; } -.repository.view.issue .merge.box .branch-update.grid .row { - padding-bottom: 1rem; -} - -.repository.view.issue .merge.box .branch-update.grid .row .icon { - margin-top: 1.1rem; -} - .repository.view.issue .comment-list:not(.prevent-before-timeline)::before { display: block; content: ""; @@ -521,7 +489,7 @@ td .commit-summary { .repository.view.issue .comment-list .timeline-item, .repository.view.issue .comment-list .timeline-item-group { - padding: 16px 0; + padding: 8px 0; } .repository.view.issue .comment-list .timeline-item-group .timeline-item { @@ -575,6 +543,11 @@ td .commit-summary { justify-content: center; } +.repository.view.issue .comment-list .timeline-item.commits-list .badge { + margin-right: 0; + height: 28px; +} + .repository.view.issue .comment-list .timeline-item .badge .svg { width: 22px; height: 22px; @@ -599,10 +572,6 @@ td .commit-summary { padding-top: 0; } -.repository.view.issue .comment-list .timeline-item.commits-list .ui.avatar { - margin-right: 0.25em; -} - .repository.view.issue .comment-list .timeline-item.event > .commit-status-link { float: right; margin-right: 8px; @@ -1224,33 +1193,6 @@ td .commit-summary { font-weight: var(--font-weight-normal); } -.empty-placeholder { - display: flex; - flex-direction: column; - align-items: center; - padding-top: 40px; - padding-bottom: 40px; -} - -.repository.packages .file-size { - white-space: nowrap; -} - -.file-view.markup { - padding: 1em 2em; -} - -.file-view.markup:has(.file-not-rendered-prompt) { - padding: 0; /* let the file-not-rendered-prompt layout itself */ -} - -.file-not-rendered-prompt { - padding: 1rem; - text-align: center; - font-size: 1rem !important; /* use consistent styles for various containers (code, markup, etc) */ - line-height: var(--line-height-default) !important; /* same as above */ -} - .repository .activity-header { display: flex; justify-content: space-between; @@ -1573,43 +1515,6 @@ td .commit-summary { border-bottom-right-radius: 4px; } -.labels-list { - display: inline-flex; - flex-wrap: wrap; - gap: 2.5px; - align-items: center; -} - -.labels-list .label, .scope-parent > .label { - padding: 0 6px; - min-height: 20px; - line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */ -} - -/* Scoped labels with different colors on left and right */ -.ui.label.scope-parent { - background: none !important; - padding: 0 !important; - gap: 0 !important; -} - -.archived-label { - filter: grayscale(0.5); - opacity: 0.5; -} - -.ui.label.scope-left { - border-bottom-right-radius: 0; - border-top-right-radius: 0; - margin-right: 0; -} - -.ui.label.scope-right { - border-bottom-left-radius: 0; - border-top-left-radius: 0; - margin-left: 0; -} - .repo-button-row { margin: 8px 0; display: flex; @@ -1623,21 +1528,17 @@ td .commit-summary { display: flex; align-items: center; gap: 0.5rem; + flex-wrap: wrap; } .repo-button-row-left { flex-grow: 1; } -.repo-button-row .button { - padding: 6px 10px !important; - height: 30px; +.repo-button-row .ui.button { flex-shrink: 0; margin: 0; -} - -.repo-button-row .button.dropdown:not(.icon) { - padding-right: 22px !important; /* normal buttons have !important paddings, so we need to override it for dropdown (Add File) icons */ + min-height: 30px; } tbody.commit-list { @@ -1724,8 +1625,7 @@ tbody.commit-list { line-height: 18px; margin: 1em; white-space: pre-wrap; - word-break: break-all; - overflow-wrap: break-word; + overflow-wrap: anywhere; } .content-history-detail-dialog .header .avatar { @@ -1783,12 +1683,12 @@ tbody.commit-list { .resolved-placeholder { display: flex; align-items: center; - font-size: 14px !important; - padding: 8px !important; - font-weight: var(--font-weight-normal) !important; - border: 1px solid var(--color-secondary) !important; - border-radius: var(--border-radius) !important; - margin: 4px !important; + justify-content: space-between; + margin: 4px; + padding: 8px; + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius); + background: var(--color-box-header); } .resolved-placeholder + .comment-code-cloud { @@ -1862,6 +1762,7 @@ tbody.commit-list { border-radius: 0; display: flex; flex-direction: column; + gap: 0.5em; } /* fomantic's last-child selector does not work with hidden last child */ @@ -2051,10 +1952,6 @@ tbody.commit-list { box-shadow: 0 0.5rem 1rem var(--color-shadow) !important; } -.migrate-entry .description { - text-wrap: balance; -} - .commits-table .commits-table-right form { display: flex; align-items: center; @@ -2216,10 +2113,11 @@ tbody.commit-list { max-width: min(400px, 90vw); } -.branch-selector-dropdown .branch-dropdown-button { +.branch-selector-dropdown .ui.button.branch-dropdown-button { margin: 0; max-width: 340px; line-height: var(--line-height-default); + padding: 0 0.5em 0 0.75em; } /* FIXME: These media selectors are not ideal (just keep them from old code). diff --git a/web_src/css/repo/clone.css b/web_src/css/repo/clone.css index c6887fbf16..53eb8b7b87 100644 --- a/web_src/css/repo/clone.css +++ b/web_src/css/repo/clone.css @@ -1,14 +1,16 @@ /* only used by "repo/empty.tmpl" */ .clone-buttons-combo { display: flex; - align-items: center; + align-items: stretch; flex: 1; } -.clone-buttons-combo input { - border-left: none !important; - border-radius: 0 !important; - height: 30px; +.clone-buttons-combo > .ui.button:not(:last-child) { + border-right: none; +} + +.ui.action.input.clone-buttons-combo input { + border-radius: 0; /* override fomantic border-radius for ".ui.input > input" */ } /* used by the clone-panel popup */ diff --git a/web_src/css/repo/file-view.css b/web_src/css/repo/file-view.css new file mode 100644 index 0000000000..907f136afe --- /dev/null +++ b/web_src/css/repo/file-view.css @@ -0,0 +1,92 @@ +.file-view tr.active .lines-num, +.file-view tr.active .lines-escape, +.file-view tr.active .lines-code { + background: var(--color-highlight-bg); +} + +/* set correct border radius on the last active lines, to avoid border overflow */ +.file-view tr.active:last-of-type .lines-code { + border-bottom-right-radius: var(--border-radius); +} + +.file-view tr.active .lines-num { + position: relative; +} + +/* add a darker "handler" at the beginning of the active line */ +.file-view tr.active .lines-num::before { + content: ""; + position: absolute; + left: 0; + width: 2px; + height: 100%; + background: var(--color-highlight-fg); +} + +.file-view .file-not-rendered-prompt { + padding: 1rem; + text-align: center; + font-size: 1rem !important; /* use consistent styles for various containers (code, markup, etc) */ + line-height: var(--line-height-default) !important; /* same as above */ +} + +/* ".code-view" is always used with ".file-view", to show the code of a file */ +.file-view.code-view { + background: var(--color-code-bg); + border-radius: var(--border-radius); +} + +.file-view.code-view table { + width: 100%; +} + +.file-view.code-view .lines-num span::after { + cursor: pointer; +} + +.file-view.code-view .lines-num:hover { + color: var(--color-text-dark); +} + +.file-view.code-view .ui.button.code-line-button { + border: 1px solid var(--color-secondary); + padding: 1px 4px; + margin: 0; + min-height: 0; + position: absolute; + left: 6px; +} + +.file-view.code-view .ui.button.code-line-button:hover { + background: var(--color-secondary); +} + +.view-raw { + display: flex; + justify-content: center; +} + +.view-raw > * { + max-width: 100%; +} + +.view-raw audio, +.view-raw video, +.view-raw img { + margin: 1rem; + border-radius: 0; + object-fit: contain; +} + +.view-raw img[src$=".svg" i] { + max-height: 600px !important; + max-width: 600px !important; +} + +.file-view-render-container { + width: 100%; +} + +.file-view-render-container :last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); /* to match the "ui segment" bottom radius */ +} diff --git a/web_src/css/repo/header.css b/web_src/css/repo/header.css index b70691435f..910648ea32 100644 --- a/web_src/css/repo/header.css +++ b/web_src/css/repo/header.css @@ -27,47 +27,3 @@ .repo-header .flex-item-trailing { flex-wrap: nowrap; } - -.repo-buttons { - align-items: center; - display: flex; - flex-flow: row wrap; - word-break: keep-all; - gap: 0.25em; -} - -.repo-buttons button[disabled] ~ .label { - opacity: var(--opacity-disabled); - color: var(--color-text-dark); - background: var(--color-light-mimic-enabled) !important; -} - -.repo-buttons button[disabled] ~ .label:hover { - color: var(--color-primary-dark-1); -} - -.repo-buttons .ui.labeled.button.disabled { - pointer-events: inherit !important; -} - -.repo-buttons .ui.labeled.button.disabled > .label { - color: var(--color-text-dark); - background: var(--color-light-mimic-enabled) !important; -} - -.repo-buttons .ui.labeled.button.disabled > .label:hover { - color: var(--color-primary-dark-1); -} - -.repo-buttons .ui.labeled.button.disabled > .button { - pointer-events: none !important; -} - -@media (max-width: 767.98px) { - .repo-buttons .ui.button, - .repo-buttons .ui.label { - padding-left: 8px; - padding-right: 8px; - margin: 0; - } -} diff --git a/web_src/css/repo/home-file-list.css b/web_src/css/repo/home-file-list.css index 46128457ed..f2ab052a54 100644 --- a/web_src/css/repo/home-file-list.css +++ b/web_src/css/repo/home-file-list.css @@ -14,17 +14,6 @@ } } -#repo-files-table .svg.octicon-file-directory-fill, -#repo-files-table .svg.octicon-file-submodule { - color: var(--color-primary); -} - -#repo-files-table .svg.octicon-file, -#repo-files-table .svg.octicon-file-symlink-file, -#repo-files-table .svg.octicon-file-directory-symlink { - color: var(--color-secondary-dark-7); -} - #repo-files-table .repo-file-item { display: contents; } diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 69c454d611..ee371f1b1c 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -58,10 +58,16 @@ flex: 0 0 15%; min-width: 0; max-height: 100vh; + position: sticky; + top: 0; + bottom: 0; + height: 100%; + overflow-y: hidden; } .repo-view-content { flex: 1; + min-width: 0; } .language-stats { diff --git a/web_src/css/repo/issue-card.css b/web_src/css/repo/issue-card.css index fb832bd05a..327919b1fe 100644 --- a/web_src/css/repo/issue-card.css +++ b/web_src/css/repo/issue-card.css @@ -7,6 +7,7 @@ padding: 8px 10px; border: 1px solid var(--color-secondary); background: var(--color-card); + color: var(--color-text); /* it can't inherit from parent because the card already has its own background */ } .issue-card-icon, @@ -28,13 +29,16 @@ display: flex; width: 100%; justify-content: space-between; - gap: 0.25em; + gap: 1em; } -.issue-card-assignees { +.issue-card-bottom-part { display: flex; + flex: 1; align-items: center; gap: 0.25em; - justify-content: end; flex-wrap: wrap; + overflow: hidden; + max-width: fit-content; + max-height: fit-content; } diff --git a/web_src/css/repo/issue-label.css b/web_src/css/repo/issue-label.css index 0a25d31da9..f75c73b50f 100644 --- a/web_src/css/repo/issue-label.css +++ b/web_src/css/repo/issue-label.css @@ -4,41 +4,46 @@ margin: 0; } -.issue-label-list .item { +.issue-label-list > .item { border-bottom: 1px solid var(--color-secondary); display: flex; padding: 1em 0; margin: 0; } -.issue-label-list .item:first-child { +.issue-label-list > .item:first-child { padding-top: 0; } -.issue-label-list .item:last-child { +.issue-label-list > .item:last-child { border-bottom: none; padding-bottom: 0; } -.issue-label-list .item .label-title { +.issue-label-list > .item .label-title { width: 33%; + padding-right: 1em; } -.issue-label-list .item .label-issues { +.issue-label-list > .item .label-issues { width: 33%; + padding-right: 1em; } -.issue-label-list .item .label-operation { +.issue-label-list > .item .label-operation { width: 33%; + display: flex; + flex-wrap: wrap; + gap: 0.5em; + justify-content: end; + align-items: center; } -.issue-label-list .item a { +.issue-label-list > .item .label-operation a { font-size: 12px; - padding-right: 10px; - color: var(--color-text-light); } -.issue-label-list .item.org-label { +.issue-label-list > .item.org-label { opacity: 0.7; } diff --git a/web_src/css/repo/linebutton.css b/web_src/css/repo/linebutton.css deleted file mode 100644 index e99d0399d1..0000000000 --- a/web_src/css/repo/linebutton.css +++ /dev/null @@ -1,18 +0,0 @@ -.code-view .lines-num:hover { - color: var(--color-text-dark) !important; -} - -.code-line-button { - border: 1px solid var(--color-secondary); - border-radius: var(--border-radius); - padding: 1px 4px !important; - position: absolute; - font-family: var(--fonts-regular); - left: 0; - transform: translateX(calc(-50% + 6px)); - cursor: pointer; -} - -.code-line-button:hover { - background: var(--color-secondary) !important; -} diff --git a/web_src/css/repo/list-header.css b/web_src/css/repo/list-header.css index e666e046d3..9d0b13933a 100644 --- a/web_src/css/repo/list-header.css +++ b/web_src/css/repo/list-header.css @@ -1,6 +1,6 @@ .list-header { display: flex; - align-items: center; + align-items: stretch; flex-wrap: wrap; gap: .5rem; } @@ -8,9 +8,8 @@ .list-header-search { display: flex; flex: 1; - align-items: center; + align-items: stretch; flex-wrap: wrap; - justify-content: center; min-width: 200px; /* to enable flexbox wrapping on mobile */ } diff --git a/web_src/css/repo/packages.css b/web_src/css/repo/packages.css new file mode 100644 index 0000000000..75675f5243 --- /dev/null +++ b/web_src/css/repo/packages.css @@ -0,0 +1,25 @@ +.packages-content { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.packages-content-left { + margin: 0 !important; + width: calc(100% - 250px - 16px); +} + +.packages-content-right { + margin: 0 !important; + width: 250px; +} + +@media (max-width: 767.98px) { + .packages-content { + flex-direction: column; + } + .packages-content-left, + .packages-content-right { + width: 100%; + } +} diff --git a/web_src/css/repo/release-tag.css b/web_src/css/repo/release-tag.css index bf8c1312f1..4b42c992ef 100644 --- a/web_src/css/repo/release-tag.css +++ b/web_src/css/repo/release-tag.css @@ -31,6 +31,7 @@ #release-list .release-entry .detail { flex: 1; margin: 0; + min-width: 0; } @media (max-width: 767.98px) { @@ -58,17 +59,24 @@ margin-bottom: 2px; /* the legacy trick to align the avatar vertically, no better solution at the moment */ } -#release-list .release-entry .detail .download .list { - padding-left: 0; +#release-list .release-entry .attachment-list { border: 1px solid var(--color-secondary); border-radius: var(--border-radius); } -#release-list .release-entry .detail .download .list li { +#release-list .release-entry .attachment-list > .item { display: flex; - justify-content: space-between; padding: 8px; - border-bottom: 1px solid var(--color-secondary); + flex-wrap: wrap; +} + +#release-list .release-entry .attachment-list > .item a { + min-width: 300px; +} + +#release-list .release-entry .attachment-list .attachment-right-info { + flex-shrink: 0; + min-width: 300px; } #release-list .release-entry .detail .download[open] summary { @@ -76,7 +84,6 @@ } #release-list .download-icon { - margin-right: .25rem; color: var(--color-text-light-1); } @@ -84,10 +91,6 @@ border-bottom: none; } -#tags-table .tag-list-row { - padding: 8px 12px; -} - #tags-table .tag-list-row-title { font-size: 18px; font-weight: var(--font-weight-normal); diff --git a/web_src/css/repo/wiki.css b/web_src/css/repo/wiki.css index ca59dadb9c..144cb1206c 100644 --- a/web_src/css/repo/wiki.css +++ b/web_src/css/repo/wiki.css @@ -39,10 +39,6 @@ min-width: 150px; } -.repository.wiki .wiki-content-sidebar .ui.message.unicode-escape-prompt p { - display: none; -} - .repository.wiki .wiki-content-footer { margin-top: 1em; } diff --git a/web_src/css/review.css b/web_src/css/review.css index 036ad017f8..23383c051c 100644 --- a/web_src/css/review.css +++ b/web_src/css/review.css @@ -1,15 +1,8 @@ -.show-outdated, -.hide-outdated { - -webkit-touch-callout: none; - -webkit-user-select: none; - user-select: none; - margin-right: 0 !important; -} - .ui.button.add-code-comment { padding: 2px; position: absolute; margin-left: -22px; + min-height: 0; z-index: 5; opacity: 0; transition: transform 0.1s ease-in-out; @@ -58,11 +51,6 @@ margin-bottom: 0.5em; } -.show-outdated:hover, -.hide-outdated:hover { - text-decoration: underline; -} - .comment-code-cloud { padding: 0.5rem 1rem !important; position: relative; diff --git a/web_src/css/shared/flex-list.css b/web_src/css/shared/flex-list.css index 0f54779252..24abe8fd9d 100644 --- a/web_src/css/shared/flex-list.css +++ b/web_src/css/shared/flex-list.css @@ -33,14 +33,6 @@ color: var(--color-primary) !important; } -.flex-item .flex-item-icon { - align-self: baseline; /* mainly used by the issue list, to align the leading icon with the title */ -} - -.flex-item .flex-item-icon + .flex-item-main { - align-self: baseline; -} - .flex-item .flex-item-trailing { display: flex; gap: 0.5rem; @@ -54,7 +46,9 @@ display: inline-flex; flex-wrap: wrap; align-items: center; - gap: .25rem; + /* labels are under effect of this gap here because they are display:contents. Ideally we should make wrapping + of labels work without display: contents and set this to a static value again. */ + gap: var(--gap-inline); max-width: 100%; color: var(--color-text); font-size: 16px; diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index 5ddee0a746..48fbd14dfb 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -185,6 +185,7 @@ gitea-theme-meta-info { --color-orange-badge-bg: #f2711c1a; --color-orange-badge-hover-bg: #f2711c4d; --color-git: #f05133; + --color-logo: #609926; /* target-based colors */ --color-body: #1b1f23; --color-box-header: #1a1d1f; diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index 1a4183c0d2..eaff717417 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -185,6 +185,7 @@ gitea-theme-meta-info { --color-orange-badge-bg: #f2711c1a; --color-orange-badge-hover-bg: #f2711c4d; --color-git: #f05133; + --color-logo: #609926; /* target-based colors */ --color-body: #ffffff; --color-box-header: #f1f3f5; diff --git a/web_src/fomantic/build/components/dropdown.css b/web_src/fomantic/build/components/dropdown.css index 58bdd8e16b..b7b35a2f05 100644 --- a/web_src/fomantic/build/components/dropdown.css +++ b/web_src/fomantic/build/components/dropdown.css @@ -1367,7 +1367,7 @@ select.ui.dropdown { } .ui.simple.dropdown .menu { position: absolute; - + /* IE hack to make dropdown icons appear inline */ display: -ms-inline-flexbox !important; display: block; diff --git a/web_src/fomantic/build/components/dropdown.js b/web_src/fomantic/build/components/dropdown.js index d3a1f7dc24..3ad0984865 100644 --- a/web_src/fomantic/build/components/dropdown.js +++ b/web_src/fomantic/build/components/dropdown.js @@ -525,6 +525,7 @@ $.fn.dropdown = function(parameters) { return true; } if(settings.onShow.call(element) !== false) { + $module.fomanticExt.onDropdownAfterFiltered.call(element); // GITEA-PATCH: callback to correctly handle the filtered items module.animate.show(function() { if( module.can.click() ) { module.bind.intent(); @@ -752,6 +753,7 @@ $.fn.dropdown = function(parameters) { if(module.is.searchSelection() && module.can.show() && module.is.focusedOnSearch() ) { module.show(); } + $module.fomanticExt.onDropdownAfterFiltered.call(element); // GITEA-PATCH: callback to correctly handle the filtered items } ; if(settings.useLabels && module.has.maxSelections()) { @@ -1130,7 +1132,11 @@ $.fn.dropdown = function(parameters) { icon: { click: function(event) { iconClicked=true; - if(module.has.search()) { + // GITEA-PATCH: official dropdown doesn't support the search input in menu + // so we need to make the menu could be shown when the search input is in menu and user clicks the icon + const searchInputInMenu = Boolean($menu.find('.search > input').length); + if(module.has.search() && !searchInputInMenu) { + // the search input is in the dropdown element (but not in the popup menu), try to focus it if(!module.is.active()) { if(settings.showOnFocus){ module.focusSearch(); @@ -4073,7 +4079,7 @@ $.fn.dropdown.settings = { search : 'input.search, .menu > .search > input, .menu input.search', sizer : '> span.sizer', text : '> .text:not(.icon)', - unselectable : '.disabled, .filtered', + unselectable : '.disabled, .filtered, .tw-hidden', // GITEA-PATCH: tw-hidden hides the item so it is also unselectable clearIcon : '> .remove.icon' }, diff --git a/web_src/fomantic/build/components/modal.js b/web_src/fomantic/build/components/modal.js index 420ecc250b..3f578ccfcc 100644 --- a/web_src/fomantic/build/components/modal.js +++ b/web_src/fomantic/build/components/modal.js @@ -467,7 +467,7 @@ $.fn.modal = function(parameters) { ignoreRepeatedEvents = false; return false; } - + $module.fomanticExt.onModalBeforeHidden.call(element); // GITEA-PATCH: handle more UI updates before hidden if( module.is.animating() || module.is.active() ) { if(settings.transition && $.fn.transition !== undefined && $module.transition('is supported')) { module.remove.active(); @@ -641,7 +641,7 @@ $.fn.modal = function(parameters) { $module .off('mousedown' + elementEventNamespace) ; - } + } $dimmer .off('mousedown' + elementEventNamespace) ; @@ -877,7 +877,7 @@ $.fn.modal = function(parameters) { ? $(document).scrollTop() + settings.padding : $(document).scrollTop() + (module.cache.contextHeight - module.cache.height - settings.padding), marginLeft: -(module.cache.width / 2) - }) + }) ; } else { $module @@ -886,7 +886,7 @@ $.fn.modal = function(parameters) { ? -(module.cache.height / 2) : settings.padding / 2, marginLeft: -(module.cache.width / 2) - }) + }) ; } module.verbose('Setting modal offset for legacy mode'); diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts index 9e41673b86..96a2759a23 100644 --- a/web_src/js/bootstrap.ts +++ b/web_src/js/bootstrap.ts @@ -2,6 +2,7 @@ // 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'; // This sets up the URL prefix used in webpack's chunk loading. // This file must be imported before any lazy-loading is being attempted. @@ -23,7 +24,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); if (!msgDiv) { const el = document.createElement('div'); - el.innerHTML = `<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>`; + 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 diff --git a/web_src/js/components/ActionRunStatus.vue b/web_src/js/components/ActionRunStatus.vue index 487d2460cc..bc3b99ab89 100644 --- a/web_src/js/components/ActionRunStatus.vue +++ b/web_src/js/components/ActionRunStatus.vue @@ -24,7 +24,7 @@ withDefaults(defineProps<{ <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class="className" v-else-if="status === 'cancelled'"/> <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class="className" v-else-if="status === 'waiting'"/> <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/> - <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'job-status-rotate ' + className" v-else-if="status === 'running'"/> + <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'circular-spin ' + className" v-else-if="status === 'running'"/> <SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown --> </span> </template> diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue index eaa9b0ffb1..296cb61cff 100644 --- a/web_src/js/components/ActivityHeatmap.vue +++ b/web_src/js/components/ActivityHeatmap.vue @@ -1,7 +1,7 @@ <script lang="ts" setup> // TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap'; -import {onMounted, ref} from 'vue'; +import {onMounted, shallowRef} from 'vue'; import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap'; defineProps<{ @@ -24,7 +24,7 @@ const colorRange = [ 'var(--color-primary-dark-4)', ]; -const endDate = ref(new Date()); +const endDate = shallowRef(new Date()); onMounted(() => { // work around issue with first legend color being rendered twice and legend cut off diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue index 0aae202d42..5ec4499e48 100644 --- a/web_src/js/components/ContextPopup.vue +++ b/web_src/js/components/ContextPopup.vue @@ -2,16 +2,16 @@ import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {getIssueColor, getIssueIcon} from '../features/issue.ts'; -import {computed, onMounted, ref} from 'vue'; +import {computed, onMounted, shallowRef, useTemplateRef} from 'vue'; import type {IssuePathInfo} from '../types.ts'; const {appSubUrl, i18n} = window.config; -const loading = ref(false); -const issue = ref(null); -const renderedLabels = ref(''); +const loading = shallowRef(false); +const issue = shallowRef(null); +const renderedLabels = shallowRef(''); const i18nErrorOccurred = i18n.error_occurred; -const i18nErrorMessage = ref(null); +const i18nErrorMessage = shallowRef(null); const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'})); const body = computed(() => { @@ -22,7 +22,7 @@ const body = computed(() => { return body; }); -const root = ref<HTMLElement | null>(null); +const root = useTemplateRef('root'); onMounted(() => { root.value.addEventListener('ce-load-context-popup', (e: CustomEventInit<IssuePathInfo>) => { diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index fc6a7bd281..e938814ec6 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -6,7 +6,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; const {appSubUrl, assetUrlPrefix, pageData} = window.config; -type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning'; +type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped'; type CommitStatusMap = { [status in CommitStatus]: { @@ -22,6 +22,7 @@ const commitStatus: CommitStatusMap = { error: {name: 'gitea-exclamation', color: 'red'}, failure: {name: 'octicon-x', color: 'red'}, warning: {name: 'gitea-exclamation', color: 'yellow'}, + skipped: {name: 'octicon-skip', color: 'grey'}, }; export default defineComponent({ @@ -38,7 +39,7 @@ export default defineComponent({ return { tab, repos: [], - reposTotalCount: 0, + reposTotalCount: null, reposFilter, archivedFilter, privateFilter, @@ -112,9 +113,6 @@ export default defineComponent({ const el = document.querySelector('#dashboard-repo-list'); this.changeReposFilter(this.reposFilter); fomanticQuery(el.querySelector('.ui.dropdown')).dropdown(); - nextTick(() => { - this.$refs.search?.focus(); - }); this.textArchivedFilterTitles = { 'archived': this.textShowOnlyArchived, @@ -218,7 +216,9 @@ export default defineComponent({ this.searchRepos(); }, - changePage(page: number) { + async changePage(page: number) { + if (this.isLoading) return; + this.page = page; if (this.page > this.finalPage) { this.page = this.finalPage; @@ -228,7 +228,7 @@ export default defineComponent({ } this.repos = []; this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); + await this.searchRepos(); }, async searchRepos() { @@ -240,12 +240,20 @@ export default defineComponent({ let response, json; try { + const firstLoad = this.reposTotalCount === null; if (!this.reposTotalCount) { const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; response = await GET(totalCountSearchURL); this.reposTotalCount = parseInt(response.headers.get('X-Total-Count') ?? '0'); } - + if (firstLoad && this.reposTotalCount) { + nextTick(() => { + // MDN: If there's no focused element, this is the Document.body or Document.documentElement. + if ((document.activeElement === document.body || document.activeElement === document.documentElement)) { + this.$refs.search.focus({preventScroll: true}); + } + }); + } response = await GET(searchedURL); json = await response.json(); } catch { @@ -298,7 +306,7 @@ export default defineComponent({ return commitStatus[status].color; }, - reposFilterKeyControl(e: KeyboardEvent) { + async reposFilterKeyControl(e: KeyboardEvent) { switch (e.key) { case 'Enter': document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click(); @@ -307,7 +315,7 @@ export default defineComponent({ if (this.activeIndex > 0) { this.activeIndex--; } else if (this.page > 1) { - this.changePage(this.page - 1); + await this.changePage(this.page - 1); this.activeIndex = this.searchLimit - 1; } break; @@ -316,17 +324,17 @@ export default defineComponent({ this.activeIndex++; } else if (this.page < this.finalPage) { this.activeIndex = 0; - this.changePage(this.page + 1); + await this.changePage(this.page + 1); } break; case 'ArrowRight': if (this.page < this.finalPage) { - this.changePage(this.page + 1); + await this.changePage(this.page + 1); } break; case 'ArrowLeft': if (this.page > 1) { - this.changePage(this.page - 1); + await this.changePage(this.page - 1); } break; } @@ -347,7 +355,7 @@ export default defineComponent({ <h4 class="ui top attached header tw-flex tw-items-center"> <div class="tw-flex-1 tw-flex tw-items-center"> {{ textMyRepos }} - <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> + <span v-if="reposTotalCount" class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> </div> <a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo"> <svg-icon name="octicon-plus"/> @@ -418,7 +426,7 @@ export default defineComponent({ </div> <div v-if="repos.length" class="ui attached table segment tw-rounded-b"> <ul class="repo-owner-name-list"> - <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id"> + <li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id"> <a class="repo-list-link muted" :href="repo.link"> <svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/> <div class="text truncate">{{ repo.full_name }}</div> @@ -426,7 +434,7 @@ export default defineComponent({ <svg-icon name="octicon-archive" :size="16"/> </div> </a> - <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link" :data-tooltip-content="repo.locale_latest_commit_status_state"> + <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || null" :data-tooltip-content="repo.locale_latest_commit_status_state"> <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl --> <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/> </a> diff --git a/web_src/js/components/DiffCommitSelector.vue b/web_src/js/components/DiffCommitSelector.vue index 16760d1cb1..a375343979 100644 --- a/web_src/js/components/DiffCommitSelector.vue +++ b/web_src/js/components/DiffCommitSelector.vue @@ -212,7 +212,7 @@ export default defineComponent({ <div class="ui scrolling dropdown custom diff-commit-selector"> <button ref="expandBtn" - class="ui basic button" + class="ui tiny basic button" @click.stop="toggleMenu()" :data-tooltip-content="locale.filter_changes_by_commit" aria-haspopup="true" diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue index 381a1c3ca4..981d10c1c1 100644 --- a/web_src/js/components/DiffFileTree.vue +++ b/web_src/js/components/DiffFileTree.vue @@ -1,21 +1,14 @@ <script lang="ts" setup> import DiffFileTreeItem from './DiffFileTreeItem.vue'; import {toggleElem} from '../utils/dom.ts'; -import {diffTreeStore} from '../modules/stores.ts'; +import {diffTreeStore} from '../modules/diff-file.ts'; import {setFileFolding} from '../features/file-fold.ts'; -import {computed, onMounted, onUnmounted} from 'vue'; -import {pathListToTree, mergeChildIfOnlyOneDir} from '../utils/filetree.ts'; +import {onMounted, onUnmounted} from 'vue'; const LOCAL_STORAGE_KEY = 'diff_file_tree_visible'; const store = diffTreeStore(); -const fileTree = computed(() => { - const result = pathListToTree(store.files); - mergeChildIfOnlyOneDir(result); // mutation - return result; -}); - onMounted(() => { // Default to true if unset store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false'; @@ -50,7 +43,7 @@ function toggleVisibility() { function updateVisibility(visible: boolean) { store.fileTreeIsVisible = visible; - localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible); + localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString()); updateState(store.fileTreeIsVisible); } @@ -67,9 +60,9 @@ function updateState(visible: boolean) { </script> <template> + <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> <div v-if="store.fileTreeIsVisible" class="diff-file-tree-items"> - <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> - <DiffFileTreeItem v-for="item in fileTree" :key="item.name" :item="item"/> + <DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/> </div> </template> diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue index 5ee0e5bcaa..f15f093ff8 100644 --- a/web_src/js/components/DiffFileTreeItem.vue +++ b/web_src/js/components/DiffFileTreeItem.vue @@ -1,18 +1,18 @@ <script lang="ts" setup> import {SvgIcon, type SvgName} from '../svg.ts'; -import {diffTreeStore} from '../modules/stores.ts'; -import {ref} from 'vue'; -import type {Item, File, FileStatus} from '../utils/filetree.ts'; +import {shallowRef} from 'vue'; +import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts'; -defineProps<{ - item: Item, +const props = defineProps<{ + item: DiffTreeEntry, }>(); const store = diffTreeStore(); -const collapsed = ref(false); +const collapsed = shallowRef(props.item.IsViewed); -function getIconForDiffStatus(pType: FileStatus) { - const diffTypes: Record<FileStatus, { name: SvgName, classes: Array<string> }> = { +function getIconForDiffStatus(pType: DiffStatus) { + const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = { + '': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case 'added': {name: 'octicon-diff-added', classes: ['text', 'green']}, 'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']}, 'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']}, @@ -20,49 +20,40 @@ function getIconForDiffStatus(pType: FileStatus) { 'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']}, 'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok }; - return diffTypes[pType]; -} - -function fileIcon(file: File) { - if (file.IsSubmodule) { - return 'octicon-file-submodule'; - } - return 'octicon-file'; + return diffTypes[pType] ?? diffTypes['']; } </script> <template> - <!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"--> - <a - v-if="item.isFile" class="item-file" - :class="{ 'selected': store.selectedItem === '#diff-' + item.file.NameHash, 'viewed': item.file.IsViewed }" - :title="item.name" :href="'#diff-' + item.file.NameHash" - > - <!-- file --> - <SvgIcon :name="fileIcon(item.file)"/> - <span class="gt-ellipsis tw-flex-1">{{ item.name }}</span> - <SvgIcon - :name="getIconForDiffStatus(item.file.Status).name" - :class="getIconForDiffStatus(item.file.Status).classes" - /> - </a> - - <template v-else-if="item.isFile === false"> - <div class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed"> + <template v-if="item.EntryMode === 'tree'"> + <div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed"> <!-- directory --> <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/> - <SvgIcon - class="text primary" - :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'" - /> - <span class="gt-ellipsis">{{ item.name }}</span> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="collapsed ? store.folderIcon : store.folderOpenIcon"/> + <span class="gt-ellipsis">{{ item.DisplayName }}</span> </div> <div v-show="!collapsed" class="sub-items"> - <DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/> + <DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/> </div> </template> + <a + v-else + class="item-file" :class="{ 'selected': store.selectedItem === '#diff-' + item.NameHash, 'viewed': item.IsViewed }" + :title="item.DisplayName" :href="'#diff-' + item.NameHash" + > + <!-- file --> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="item.FileIcon"/> + <span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span> + <SvgIcon + :name="getIconForDiffStatus(item.DiffStatus).name" + :class="getIconForDiffStatus(item.DiffStatus).classes" + /> + </a> </template> + <style scoped> a, a:hover { @@ -88,7 +79,8 @@ a:hover { border-radius: 4px; } -.item-file.viewed { +.item-file.viewed, +.item-directory.viewed { color: var(--color-text-light-3); } diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index 4f291f5ca1..b2c28414c0 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -1,19 +1,19 @@ <script lang="ts" setup> -import {computed, onMounted, onUnmounted, ref, watch} from 'vue'; +import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue'; import {SvgIcon} from '../svg.ts'; import {toggleElem} from '../utils/dom.ts'; const {csrfToken, pageData} = window.config; -const mergeForm = ref(pageData.pullRequestMergeForm); +const mergeForm = pageData.pullRequestMergeForm; -const mergeTitleFieldValue = ref(''); -const mergeMessageFieldValue = ref(''); -const deleteBranchAfterMerge = ref(false); -const autoMergeWhenSucceed = ref(false); +const mergeTitleFieldValue = shallowRef(''); +const mergeMessageFieldValue = shallowRef(''); +const deleteBranchAfterMerge = shallowRef(false); +const autoMergeWhenSucceed = shallowRef(false); -const mergeStyle = ref(''); -const mergeStyleDetail = ref({ +const mergeStyle = shallowRef(''); +const mergeStyleDetail = shallowRef({ hideMergeMessageTexts: false, textDoMerge: '', mergeTitleFieldText: '', @@ -21,33 +21,33 @@ const mergeStyleDetail = ref({ hideAutoMerge: false, }); -const mergeStyleAllowedCount = ref(0); +const mergeStyleAllowedCount = shallowRef(0); -const showMergeStyleMenu = ref(false); -const showActionForm = ref(false); +const showMergeStyleMenu = shallowRef(false); +const showActionForm = shallowRef(false); const mergeButtonStyleClass = computed(() => { - if (mergeForm.value.allOverridableChecksOk) return 'primary'; + if (mergeForm.allOverridableChecksOk) return 'primary'; return autoMergeWhenSucceed.value ? 'primary' : 'red'; }); const forceMerge = computed(() => { - return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk; + return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk; }); watch(mergeStyle, (val) => { - mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e: any) => e.name === val); + mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val); for (const elem of document.querySelectorAll('[data-pull-merge-style]')) { toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val); } }); onMounted(() => { - mergeStyleAllowedCount.value = mergeForm.value.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0); + mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0); - let mergeStyle = mergeForm.value.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name; - if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e: any) => e.allowed)?.name; - switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow); + let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name; + if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name; + switchMergeStyle(mergeStyle, !mergeForm.canMergeNow); document.addEventListener('mouseup', hideMergeStyleMenu); }); @@ -63,7 +63,7 @@ function hideMergeStyleMenu() { function toggleActionForm(show: boolean) { showActionForm.value = show; if (!show) return; - deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge; + deleteBranchAfterMerge.value = mergeForm.defaultDeleteBranchAfterMerge; mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText; mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText; } @@ -74,7 +74,7 @@ function switchMergeStyle(name: string, autoMerge = false) { } function clearMergeMessage() { - mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage; + mergeMessageFieldValue.value = mergeForm.defaultMergeMessage; } </script> diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 2ef528620d..2eb2211269 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -7,6 +7,7 @@ import {formatDatetime} from '../utils/time.ts'; import {renderAnsi} from '../render/ansi.ts'; import {POST, DELETE} from '../modules/fetch.ts'; import type {IntervalId} from '../types.ts'; +import {toggleFullScreen} from '../utils.ts'; // see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts" type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked'; @@ -176,7 +177,7 @@ export default defineComponent({ }, }, - async mounted() { // eslint-disable-line @typescript-eslint/no-misused-promises + async mounted() { // load job data and then auto-reload periodically // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener await this.loadJob(); @@ -416,21 +417,7 @@ export default defineComponent({ toggleFullScreen() { this.isFullScreen = !this.isFullScreen; - const fullScreenEl = document.querySelector('.action-view-right'); - const outerEl = document.querySelector('.full.height'); - const actionBodyEl = document.querySelector('.action-view-body'); - const headerEl = document.querySelector('#navbar'); - const contentEl = document.querySelector('.page-content'); - const footerEl = document.querySelector('.page-footer'); - toggleElem(headerEl, !this.isFullScreen); - toggleElem(contentEl, !this.isFullScreen); - toggleElem(footerEl, !this.isFullScreen); - // move .action-view-right to new parent - if (this.isFullScreen) { - outerEl.append(fullScreenEl); - } else { - actionBodyEl.append(fullScreenEl); - } + toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body'); }, async hashChangeListener() { const selectedLogStep = window.location.hash; @@ -452,7 +439,8 @@ export default defineComponent({ }); </script> <template> - <div class="ui container action-view-container"> + <!-- make the view container full width to make users easier to read logs --> + <div class="ui fluid container"> <div class="action-view-header"> <div class="action-info-summary"> <div class="action-info-summary-title"> @@ -508,14 +496,24 @@ export default defineComponent({ {{ locale.artifactsTitle }} </div> <ul class="job-artifacts-list"> - <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name"> - <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> - <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }} - </a> - <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete"> - <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/> - </a> - </li> + <template v-for="artifact in artifacts" :key="artifact.name"> + <li class="job-artifacts-item"> + <template v-if="artifact.status !== 'expired'"> + <a class="flex-text-inline" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> + <SvgIcon name="octicon-file" class="text black"/> + <span class="gt-ellipsis">{{ artifact.name }}</span> + </a> + <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)"> + <SvgIcon name="octicon-trash" class="text black"/> + </a> + </template> + <span v-else class="flex-text-inline text light grey"> + <SvgIcon name="octicon-file"/> + <span class="gt-ellipsis">{{ artifact.name }}</span> + <span class="ui label text light grey tw-flex-shrink-0">{{ locale.artifactExpired }}</span> + </span> + </li> + </template> </ul> </div> </div> @@ -574,7 +572,7 @@ export default defineComponent({ <!-- If the job is done and the job step log is loaded for the first time, show the loading icon currentJobStepsStates[i].cursor === null means the log is loaded for the first time --> - <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 circular-spin"/> <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/> <ActionRunStatus :status="jobStep.status" class="tw-mr-2"/> @@ -677,6 +675,7 @@ export default defineComponent({ padding: 6px; display: flex; justify-content: space-between; + align-items: center; } .job-artifacts-list { @@ -684,10 +683,6 @@ export default defineComponent({ list-style: none; } -.job-artifacts-icon { - padding-right: 3px; -} - .job-brief-list { display: flex; flex-direction: column; @@ -896,16 +891,6 @@ export default defineComponent({ <style> /* eslint-disable-line vue-scoped-css/enforce-style-type */ /* some elements are not managed by vue, so we need to use global style */ -.job-status-rotate { - animation: job-status-rotate-keyframes 1s linear infinite; -} - -@keyframes job-status-rotate-keyframes { - 100% { - transform: rotate(-360deg); - } -} - .job-step-section { margin: 10px; } @@ -955,7 +940,6 @@ export default defineComponent({ .job-step-logs .job-log-line .log-msg { flex: 1; - word-break: break-all; white-space: break-spaces; margin-left: 10px; overflow-wrap: anywhere; diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue index 77b85bd7e2..bbdfda41d0 100644 --- a/web_src/js/components/RepoActivityTopAuthors.vue +++ b/web_src/js/components/RepoActivityTopAuthors.vue @@ -1,9 +1,9 @@ <script lang="ts" setup> // @ts-expect-error - module exports no types import {VueBarGraph} from 'vue-bar-graph'; -import {computed, onMounted, ref} from 'vue'; +import {computed, onMounted, shallowRef, useTemplateRef} from 'vue'; -const colors = ref({ +const colors = shallowRef({ barColor: 'green', textColor: 'black', textAltColor: 'white', @@ -41,8 +41,8 @@ const graphWidth = computed(() => { return activityTopAuthors.length * 40; }); -const styleElement = ref<HTMLElement | null>(null); -const altStyleElement = ref<HTMLElement | null>(null); +const styleElement = useTemplateRef('styleElement'); +const altStyleElement = useTemplateRef('altStyleElement'); onMounted(() => { const refStyle = window.getComputedStyle(styleElement.value); diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue index 820e69d9ab..8e3a29a0e0 100644 --- a/web_src/js/components/RepoBranchTagSelector.vue +++ b/web_src/js/components/RepoBranchTagSelector.vue @@ -216,14 +216,15 @@ export default defineComponent({ }); </script> <template> - <div class="ui dropdown custom branch-selector-dropdown ellipsis-items-nowrap"> - <div tabindex="0" class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible"> + <div class="ui dropdown custom branch-selector-dropdown ellipsis-text-items"> + <div tabindex="0" class="ui compact button branch-dropdown-button" @click="menuVisible = !menuVisible"> <span class="flex-text-block gt-ellipsis"> <template v-if="dropdownFixedText">{{ dropdownFixedText }}</template> <template v-else> <svg-icon v-if="currentRefType === 'tag'" name="octicon-tag"/> - <svg-icon v-else name="octicon-git-branch"/> - <strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong> + <svg-icon v-else-if="currentRefType === 'branch'" name="octicon-git-branch"/> + <svg-icon v-else name="octicon-git-commit"/> + <strong ref="dropdownRefName" class="tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong> </template> </span> <svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/> diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue index 7696996cf6..f331a26fe9 100644 --- a/web_src/js/components/RepoCodeFrequency.vue +++ b/web_src/js/components/RepoCodeFrequency.vue @@ -23,7 +23,7 @@ import { import {chartJsColors} from '../utils/color.ts'; import {sleep} from '../utils.ts'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; -import {onMounted, ref} from 'vue'; +import {onMounted, shallowRef} from 'vue'; const {pageData} = window.config; @@ -47,10 +47,10 @@ defineProps<{ }; }>(); -const isLoading = ref(false); -const errorText = ref(''); -const repoLink = ref(pageData.repoLink || []); -const data = ref<DayData[]>([]); +const isLoading = shallowRef(false); +const errorText = shallowRef(''); +const repoLink = pageData.repoLink; +const data = shallowRef<DayData[]>([]); onMounted(() => { fetchGraphData(); @@ -61,7 +61,7 @@ async function fetchGraphData() { try { let response: Response; do { - response = await GET(`${repoLink.value}/activity/code-frequency/data`); + response = await GET(`${repoLink}/activity/code-frequency/data`); if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying } @@ -150,7 +150,7 @@ const options: ChartOptions<'line'> = { <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue index 6ad2c848b1..754acb997d 100644 --- a/web_src/js/components/RepoContributors.vue +++ b/web_src/js/components/RepoContributors.vue @@ -353,12 +353,12 @@ export default defineComponent({ </div> <div> <!-- Contribution type --> - <div class="ui dropdown jump" id="repo-contributors"> + <div class="ui floating dropdown jump" id="repo-contributors"> <div class="ui basic compact button"> <span class="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong> <svg-icon name="octicon-triangle-down" :size="14"/> </div> - <div class="menu"> + <div class="left menu"> <div :class="['item', {'selected': type === 'commits'}]" data-value="commits"> {{ locale.contributionType.commits }} </div> @@ -375,7 +375,7 @@ export default defineComponent({ <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> @@ -397,7 +397,7 @@ export default defineComponent({ <div class="ui top attached header tw-flex tw-flex-1"> <b class="ui right">#{{ index + 1 }}</b> <a :href="contributor.home_link"> - <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt=""> + <img loading="lazy" class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt=""> </a> <div class="tw-ml-2"> <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a> diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue index 10e1fdd70c..27aa27dfc3 100644 --- a/web_src/js/components/RepoRecentCommits.vue +++ b/web_src/js/components/RepoRecentCommits.vue @@ -21,7 +21,7 @@ import { import {chartJsColors} from '../utils/color.ts'; import {sleep} from '../utils.ts'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; -import {onMounted, ref} from 'vue'; +import {onMounted, ref, shallowRef} from 'vue'; const {pageData} = window.config; @@ -43,9 +43,9 @@ defineProps<{ }; }>(); -const isLoading = ref(false); -const errorText = ref(''); -const repoLink = ref(pageData.repoLink || []); +const isLoading = shallowRef(false); +const errorText = shallowRef(''); +const repoLink = pageData.repoLink; const data = ref<DayData[]>([]); onMounted(() => { @@ -57,7 +57,7 @@ async function fetchGraphData() { try { let response: Response; do { - response = await GET(`${repoLink.value}/activity/recent-commits/data`); + response = await GET(`${repoLink}/activity/recent-commits/data`); if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying } @@ -128,7 +128,7 @@ const options: ChartOptions<'bar'> = { <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> diff --git a/web_src/js/components/ViewFileTree.vue b/web_src/js/components/ViewFileTree.vue index 1820c47e7a..1f90f92586 100644 --- a/web_src/js/components/ViewFileTree.vue +++ b/web_src/js/components/ViewFileTree.vue @@ -1,10 +1,9 @@ <script lang="ts" setup> import ViewFileTreeItem from './ViewFileTreeItem.vue'; -import {onMounted, ref} from 'vue'; -import {pathEscapeSegments} from '../utils/url.ts'; -import {GET} from '../modules/fetch.ts'; +import {onMounted, useTemplateRef} from 'vue'; +import {createViewFileTreeStore} from './ViewFileTreeStore.ts'; -const elRoot = ref<HTMLElement | null>(null); +const elRoot = useTemplateRef('elRoot'); const props = defineProps({ repoLink: {type: String, required: true}, @@ -12,43 +11,20 @@ const props = defineProps({ currentRefNameSubURL: {type: String, required: true}, }); -const files = ref([]); -const selectedItem = ref(''); - -async function loadChildren(treePath: string, subPath: string = '') { - const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`); - const json = await response.json(); - return json.fileTreeNodes ?? null; -} - -async function loadViewContent(url: string) { - url = url.includes('?') ? url.replace('?', '?only_content=true') : `${url}?only_content=true`; - const response = await GET(url); - document.querySelector('.repo-view-content').innerHTML = await response.text(); -} - -async function navigateTreeView(treePath: string) { - const url = `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`; - window.history.pushState({treePath, url}, null, url); - selectedItem.value = treePath; - await loadViewContent(url); -} - +const store = createViewFileTreeStore(props); onMounted(async () => { - selectedItem.value = props.treePath; - files.value = await loadChildren('', props.treePath); + store.rootFiles = await store.loadChildren('', props.treePath); elRoot.value.closest('.is-loading')?.classList?.remove('is-loading'); window.addEventListener('popstate', (e) => { - selectedItem.value = e.state?.treePath || ''; - if (e.state?.url) loadViewContent(e.state.url); + store.selectedItem = e.state?.treePath || ''; + if (e.state?.url) store.loadViewContent(e.state.url); }); }); </script> <template> <div class="view-file-tree-items" ref="elRoot"> - <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> - <ViewFileTreeItem v-for="item in files" :key="item.name" :item="item" :selected-item="selectedItem" :navigate-view-content="navigateTreeView" :load-children="loadChildren"/> + <ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/> </div> </template> diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue index 4dffc86a1b..5173c7eb46 100644 --- a/web_src/js/components/ViewFileTreeItem.vue +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -1,10 +1,14 @@ <script lang="ts" setup> import {SvgIcon} from '../svg.ts'; -import {ref} from 'vue'; +import {isPlainClick} from '../utils/dom.ts'; +import {shallowRef} from 'vue'; +import {type createViewFileTreeStore} from './ViewFileTreeStore.ts'; type Item = { entryName: string; - entryMode: string; + entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown'; + entryIcon: string; + entryIconOpen: string; fullPath: string; submoduleUrl?: string; children?: Item[]; @@ -12,99 +16,67 @@ type Item = { const props = defineProps<{ item: Item, - navigateViewContent:(treePath: string) => void, - loadChildren:(treePath: string, subPath?: string) => Promise<Item[]>, - selectedItem?: string, + store: ReturnType<typeof createViewFileTreeStore> }>(); -const isLoading = ref(false); -const children = ref(props.item.children); -const collapsed = ref(!props.item.children); +const store = props.store; +const isLoading = shallowRef(false); +const children = shallowRef(props.item.children); +const collapsed = shallowRef(!props.item.children); const doLoadChildren = async () => { collapsed.value = !collapsed.value; - if (!collapsed.value && props.loadChildren) { + if (!collapsed.value) { isLoading.value = true; try { - children.value = await props.loadChildren(props.item.fullPath); + children.value = await store.loadChildren(props.item.fullPath); } finally { isLoading.value = false; } } }; -const doLoadDirContent = () => { - doLoadChildren(); - props.navigateViewContent(props.item.fullPath); +const onItemClick = (e: MouseEvent) => { + // only handle the click event with page partial reloading if the user didn't press any special key + // let browsers handle special keys like "Ctrl+Click" + if (!isPlainClick(e)) return; + e.preventDefault(); + if (props.item.entryMode === 'tree') doLoadChildren(); + store.navigateTreeView(props.item.fullPath); }; -const doLoadFileContent = () => { - props.navigateViewContent(props.item.fullPath); -}; - -const doGotoSubModule = () => { - location.href = props.item.submoduleUrl; -}; </script> -<!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"--> <template> - <div - v-if="item.entryMode === 'commit'" class="tree-item type-submodule" + <a + class="tree-item silenced" + :class="{ + 'selected': store.selectedItem === item.fullPath, + 'type-submodule': item.entryMode === 'commit', + 'type-directory': item.entryMode === 'tree', + 'type-symlink': item.entryMode === 'symlink', + 'type-file': item.entryMode === 'blob' || item.entryMode === 'exec', + }" :title="item.entryName" - @click.stop="doGotoSubModule" + :href="store.buildTreePathWebUrl(item.fullPath)" + @click.stop="onItemClick" > - <!-- submodule --> - <div class="item-content"> - <SvgIcon class="text primary" name="octicon-file-submodule"/> - <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span> + <div v-if="item.entryMode === 'tree'" class="item-toggle"> + <SvgIcon v-if="isLoading" name="octicon-sync" class="circular-spin"/> + <SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop.prevent="doLoadChildren"/> </div> - </div> - <div - v-else-if="item.entryMode === 'symlink'" class="tree-item type-symlink" - :class="{'selected': selectedItem === item.fullPath}" - :title="item.entryName" - @click.stop="doLoadFileContent" - > - <!-- symlink --> <div class="item-content"> - <SvgIcon name="octicon-file-symlink-file"/> - <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span> - </div> - </div> - <div - v-else-if="item.entryMode !== 'tree'" class="tree-item type-file" - :class="{'selected': selectedItem === item.fullPath}" - :title="item.entryName" - @click.stop="doLoadFileContent" - > - <!-- file --> - <div class="item-content"> - <SvgIcon name="octicon-file"/> - <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span> - </div> - </div> - <div - v-else class="tree-item type-directory" - :class="{'selected': selectedItem === item.fullPath}" - :title="item.entryName" - @click.stop="doLoadDirContent" - > - <!-- directory --> - <div class="item-toggle"> - <SvgIcon v-if="isLoading" name="octicon-sync" class="job-status-rotate"/> - <SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop="doLoadChildren"/> - </div> - <div class="item-content"> - <SvgIcon class="text primary" :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"/> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/> <span class="gt-ellipsis">{{ item.entryName }}</span> </div> - </div> + </a> <div v-if="children?.length" v-show="!collapsed" class="sub-items"> - <ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :selected-item="selectedItem" :navigate-view-content="navigateViewContent" :load-children="loadChildren"/> + <ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :store="store"/> </div> </template> + <style scoped> .sub-items { display: flex; @@ -149,7 +121,7 @@ const doGotoSubModule = () => { grid-area: content; display: flex; align-items: center; - gap: 0.25em; + gap: 0.5em; text-overflow: ellipsis; min-width: 0; } diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts new file mode 100644 index 0000000000..e2155bd58a --- /dev/null +++ b/web_src/js/components/ViewFileTreeStore.ts @@ -0,0 +1,45 @@ +import {reactive} from 'vue'; +import {GET} from '../modules/fetch.ts'; +import {pathEscapeSegments} from '../utils/url.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; + +export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) { + const store = reactive({ + rootFiles: [], + selectedItem: props.treePath, + + async loadChildren(treePath: string, subPath: string = '') { + const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`); + const json = await response.json(); + const poolSvgs = []; + for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) { + if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent); + } + if (poolSvgs.length) { + const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`); + svgContainer.innerHTML = poolSvgs.join(''); + document.body.append(svgContainer); + } + return json.fileTreeNodes ?? null; + }, + + async loadViewContent(url: string) { + url = url.includes('?') ? url.replace('?', '?only_content=true') : `${url}?only_content=true`; + const response = await GET(url); + document.querySelector('.repo-view-content').innerHTML = await response.text(); + }, + + async navigateTreeView(treePath: string) { + const url = store.buildTreePathWebUrl(treePath); + window.history.pushState({treePath, url}, null, url); + store.selectedItem = treePath; + await store.loadViewContent(url); + }, + + buildTreePathWebUrl(treePath: string) { + return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`; + }, + }); + return store; +} diff --git a/web_src/js/features/admin/common.ts b/web_src/js/features/admin/common.ts index 3652ea7d39..4ed5d62eee 100644 --- a/web_src/js/features/admin/common.ts +++ b/web_src/js/features/admin/common.ts @@ -1,7 +1,6 @@ import {checkAppUrl} from '../common-page.ts'; import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts'; import {POST} from '../../modules/fetch.ts'; -import {initAvatarUploaderWithCropper} from '../comp/Cropper.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {appSubUrl} = window.config; @@ -23,8 +22,6 @@ export function initAdminCommon(): void { initAdminUser(); initAdminAuthentication(); initAdminNotice(); - - queryElems(document, '.avatar-file-with-cropper', initAvatarUploaderWithCropper); } function initAdminUser() { diff --git a/web_src/js/features/common-button.test.ts b/web_src/js/features/common-button.test.ts new file mode 100644 index 0000000000..f41bafbc79 --- /dev/null +++ b/web_src/js/features/common-button.test.ts @@ -0,0 +1,14 @@ +import {assignElementProperty} from './common-button.ts'; + +test('assignElementProperty', () => { + const elForm = document.createElement('form'); + assignElementProperty(elForm, 'action', '/test-link'); + expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL + assignElementProperty(elForm, 'text-content', 'dummy'); + expect(elForm.textContent).toBe('dummy'); + + const elInput = document.createElement('input'); + expect(elInput.readOnly).toBe(false); + assignElementProperty(elInput, 'read-only', 'true'); + expect(elInput.readOnly).toBe(true); +}); diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index 003bfbce5d..22a7890857 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -1,5 +1,5 @@ import {POST} from '../modules/fetch.ts'; -import {addDelegatedEventListener, hideElem, showElem, toggleElem} from '../utils/dom.ts'; +import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {camelize} from 'vue'; @@ -43,13 +43,16 @@ export function initGlobalDeleteButton(): void { fomanticQuery(modal).modal({ closable: false, - onApprove: async () => { + onApprove: () => { // if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."` if (btn.getAttribute('data-type') === 'form') { const formSelector = btn.getAttribute('data-form'); const form = document.querySelector<HTMLFormElement>(formSelector); if (!form) throw new Error(`no form named ${formSelector} found`); + modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal + form.classList.add('is-loading'); form.submit(); + return false; // prevent modal from closing automatically } // prepare an AJAX form by data attributes @@ -62,12 +65,15 @@ export function initGlobalDeleteButton(): void { postData.append('id', value); } } - - const response = await POST(btn.getAttribute('data-url'), {data: postData}); - if (response.ok) { - const data = await response.json(); - window.location.href = data.redirect; - } + (async () => { + const response = await POST(btn.getAttribute('data-url'), {data: postData}); + if (response.ok) { + const data = await response.json(); + window.location.href = data.redirect; + } + })(); + modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal + return false; // prevent modal from closing automatically }, }).modal('show'); }); @@ -79,10 +85,11 @@ function onShowPanelClick(el: HTMLElement, e: MouseEvent) { // if it has "toggle" class, it toggles the panel e.preventDefault(); const sel = el.getAttribute('data-panel'); - if (el.classList.contains('toggle')) { - toggleElem(sel); - } else { - showElem(sel); + const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel); + for (const elem of elems) { + if (isElemVisible(elem as HTMLElement)) { + elem.querySelector<HTMLElement>('[autofocus]')?.focus(); + } } } @@ -102,6 +109,21 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) { throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code } +export function assignElementProperty(el: any, name: string, val: string) { + name = camelize(name); + const old = el[name]; + if (typeof old === 'boolean') { + el[name] = val === 'true'; + } else if (typeof old === 'number') { + el[name] = parseFloat(val); + } else if (typeof old === 'string') { + el[name] = val; + } else { + // in the future, we could introduce a better typing system like `data-modal-form.action:string="..."` + throw new Error(`cannot assign element property ${name} by value ${val}`); + } +} + function onShowModalClick(el: HTMLElement, e: MouseEvent) { // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute. // Each "data-modal-{target}" attribute will be filled to target element's value or text-content. @@ -109,7 +131,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { // * Then, try to query '[name=target]' // * Then, try to query '.target' // * Then, try to query 'target' as HTML tag - // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set. + // If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName". e.preventDefault(); const modalSelector = el.getAttribute('data-modal'); const elModal = document.querySelector(modalSelector); @@ -122,7 +144,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { } const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length); - const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.'); + const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.'); // try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag" const attrTarget = elModal.querySelector(`#${attrTargetName}`) || elModal.querySelector(`[name=${attrTargetName}]`) || @@ -133,8 +155,8 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { continue; } - if (attrTargetAttr) { - (attrTarget as any)[camelize(attrTargetAttr)] = attrib.value; + if (attrTargetProp) { + assignElementProperty(attrTarget, attrTargetProp, attrib.value); } else if (attrTarget.matches('input, textarea')) { (attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox } else { @@ -142,13 +164,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { } } - fomanticQuery(elModal).modal('setting', { - onApprove: () => { - // "form-fetch-action" can handle network errors gracefully, - // so keep the modal dialog to make users can re-submit the form if anything wrong happens. - if (elModal.querySelector('.form-fetch-action')) return false; - }, - }).modal('show'); + fomanticQuery(elModal).modal('show'); } export function initGlobalButtons(): void { diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts index 2da481e521..3ca361b6e2 100644 --- a/web_src/js/features/common-fetch-action.ts +++ b/web_src/js/features/common-fetch-action.ts @@ -1,11 +1,11 @@ import {request} from '../modules/fetch.ts'; -import {showErrorToast} from '../modules/toast.ts'; -import {addDelegatedEventListener, submitEventSubmitter} from '../utils/dom.ts'; -import {confirmModal} from './comp/ConfirmModal.ts'; +import {hideToastsAll, showErrorToast} from '../modules/toast.ts'; +import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts'; +import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts'; import type {RequestOpts} from '../types.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; -const {appSubUrl, i18n} = window.config; +const {appSubUrl} = window.config; // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location" // more details are in the backend's fetch-redirect handler @@ -23,10 +23,20 @@ function fetchActionDoRedirect(redirect: string) { } async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) { + const showErrorForResponse = (code: number, message: string) => { + showErrorToast(`Error ${code || 'request'}: ${message}`); + }; + + let respStatus = 0; + let respText = ''; try { + hideToastsAll(); const resp = await request(url, opt); - if (resp.status === 200) { - let {redirect} = await resp.json(); + respStatus = resp.status; + respText = await resp.text(); + const respJson = JSON.parse(respText); + if (respStatus === 200) { + let {redirect} = respJson; redirect = redirect || actionElem.getAttribute('data-redirect'); ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading if (redirect) { @@ -35,29 +45,32 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R window.location.reload(); } return; - } else if (resp.status >= 400 && resp.status < 500) { - const data = await resp.json(); + } + + if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) { // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. - if (data.errorMessage) { - showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'}); - } else { - showErrorToast(`server error: ${resp.status}`); - } + showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'}); } else { - showErrorToast(`server error: ${resp.status}`); + showErrorForResponse(respStatus, respText); } } catch (e) { - if (e.name !== 'AbortError') { - console.error('error when doRequest', e); - showErrorToast(`${i18n.network_error} ${e}`); + if (e.name === 'SyntaxError') { + showErrorForResponse(respStatus, (respText || '').substring(0, 100)); + } else if (e.name !== 'AbortError') { + console.error('fetchActionDoRequest error', e); + showErrorForResponse(respStatus, `${e}`); } } actionElem.classList.remove('is-loading', 'loading-icon-2px'); } -async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) { +async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) { e.preventDefault(); + await submitFormFetchAction(formEl, submitEventSubmitter(e)); +} + +export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitter?: HTMLElement) { if (formEl.classList.contains('is-loading')) return; formEl.classList.add('is-loading'); @@ -66,9 +79,8 @@ async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) { } const formMethod = formEl.getAttribute('method') || 'get'; - const formActionUrl = formEl.getAttribute('action'); + const formActionUrl = formEl.getAttribute('action') || window.location.href; const formData = new FormData(formEl); - const formSubmitter = submitEventSubmitter(e); const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')]; if (submitterName) { formData.append(submitterName, submitterValue || ''); @@ -96,36 +108,52 @@ async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) { await fetchActionDoRequest(formEl, reqUrl, reqOpt); } -async function linkAction(el: HTMLElement, e: Event) { +async function onLinkActionClick(el: HTMLElement, e: Event) { // A "link-action" can post AJAX request to its "data-url" // Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading. - // If the "link-action" has "data-modal-confirm" attribute, a confirm modal dialog will be shown before taking action. + // If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action. + // Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog. e.preventDefault(); const url = el.getAttribute('data-url'); const doRequest = async () => { - if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but A doesn't have disabled attribute + if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'}); if ('disabled' in el) el.disabled = false; }; - const modalConfirmContent = el.getAttribute('data-modal-confirm') || - el.getAttribute('data-modal-confirm-content') || ''; - if (!modalConfirmContent) { + let elModal: HTMLElement | null = null; + const dataModalConfirm = el.getAttribute('data-modal-confirm') || ''; + if (dataModalConfirm.startsWith('#')) { + // eslint-disable-next-line unicorn/prefer-query-selector + elModal = document.getElementById(dataModalConfirm.substring(1)); + if (elModal) { + elModal = createElementFromHTML(elModal.outerHTML); + elModal.removeAttribute('id'); + } + } + if (!elModal) { + const modalConfirmContent = dataModalConfirm || el.getAttribute('data-modal-confirm-content') || ''; + if (modalConfirmContent) { + const isRisky = el.classList.contains('red') || el.classList.contains('negative'); + elModal = createConfirmModal({ + header: el.getAttribute('data-modal-confirm-header') || '', + content: modalConfirmContent, + confirmButtonColor: isRisky ? 'red' : 'primary', + }); + } + } + + if (!elModal) { await doRequest(); return; } - const isRisky = el.classList.contains('red') || el.classList.contains('negative'); - if (await confirmModal({ - header: el.getAttribute('data-modal-confirm-header') || '', - content: modalConfirmContent, - confirmButtonColor: isRisky ? 'red' : 'primary', - })) { + if (await confirmModal(elModal)) { await doRequest(); } } export function initGlobalFetchAction() { - addDelegatedEventListener(document, 'submit', '.form-fetch-action', formFetchAction); - addDelegatedEventListener(document, 'click', '.link-action', linkAction); + addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit); + addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick); } diff --git a/web_src/js/features/common-issue-list.ts b/web_src/js/features/common-issue-list.ts index e207364794..037529bd10 100644 --- a/web_src/js/features/common-issue-list.ts +++ b/web_src/js/features/common-issue-list.ts @@ -1,4 +1,4 @@ -import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts'; +import {isElemVisible, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts'; import {GET} from '../modules/fetch.ts'; const {appSubUrl} = window.config; @@ -28,7 +28,7 @@ export function parseIssueListQuickGotoLink(repoLink: string, searchText: string } export function initCommonIssueListQuickGoto() { - const goto = document.querySelector('#issue-list-quick-goto'); + const goto = document.querySelector<HTMLElement>('#issue-list-quick-goto'); if (!goto) return; const form = goto.closest('form'); @@ -37,7 +37,7 @@ export function initCommonIssueListQuickGoto() { form.addEventListener('submit', (e) => { // if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly - let doQuickGoto = !isElemHidden(goto); + let doQuickGoto = isElemVisible(goto); const submitter = submitEventSubmitter(e); if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false; if (!doQuickGoto) return; diff --git a/web_src/js/features/common-organization.ts b/web_src/js/features/common-organization.ts index 9d5964c4c7..a1f19bedea 100644 --- a/web_src/js/features/common-organization.ts +++ b/web_src/js/features/common-organization.ts @@ -1,6 +1,5 @@ import {initCompLabelEdit} from './comp/LabelEdit.ts'; -import {queryElems, toggleElem} from '../utils/dom.ts'; -import {initAvatarUploaderWithCropper} from './comp/Cropper.ts'; +import {toggleElem} from '../utils/dom.ts'; export function initCommonOrganization() { if (!document.querySelectorAll('.organization').length) { @@ -14,6 +13,4 @@ export function initCommonOrganization() { // Labels initCompLabelEdit('.page-content.organization.settings.labels'); - - queryElems(document, '.avatar-file-with-cropper', initAvatarUploaderWithCropper); } diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts index 6aabfc5d4f..5a02ee7a6a 100644 --- a/web_src/js/features/common-page.ts +++ b/web_src/js/features/common-page.ts @@ -3,6 +3,7 @@ import {showGlobalErrorMessage} from '../bootstrap.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {queryElems} from '../utils/dom.ts'; import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; +import {initAvatarUploaderWithCropper} from './comp/Cropper.ts'; const {appUrl} = window.config; @@ -80,6 +81,10 @@ export function initGlobalTabularMenu() { fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab(); } +export function initGlobalAvatarUploader() { + registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper); +} + // for performance considerations, it only uses performant syntax function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) { if (el.type !== 'hidden' && diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts index 1ce490ec2e..97a73eace6 100644 --- a/web_src/js/features/comp/ConfirmModal.ts +++ b/web_src/js/features/comp/ConfirmModal.ts @@ -1,24 +1,33 @@ import {svg} from '../../svg.ts'; -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../../utils/html.ts'; import {createElementFromHTML} from '../../utils/dom.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {i18n} = window.config; -export function confirmModal({header = '', content = '', confirmButtonColor = 'primary'} = {}): Promise<boolean> { - return new Promise((resolve) => { - const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : ''; - const modal = createElementFromHTML(` - <div class="ui g-modal-confirm modal"> - ${headerHtml} - <div class="content">${htmlEscape(content)}</div> - <div class="actions"> - <button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button> - <button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button> - </div> +type ConfirmModalOptions = { + header?: string; + content?: string; + confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue'; +} + +export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement { + const headerHtml = header ? html`<div class="header">${header}</div>` : ''; + return createElementFromHTML(html` + <div class="ui g-modal-confirm modal"> + ${htmlRaw(headerHtml)} + <div class="content">${content}</div> + <div class="actions"> + <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button> + <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button> </div> - `); - document.body.append(modal); + </div> + `.trim()); +} + +export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> { + if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal); + return new Promise((resolve) => { const $modal = fomanticQuery(modal); $modal.modal({ onApprove() { diff --git a/web_src/js/features/comp/EditorUpload.test.ts b/web_src/js/features/comp/EditorUpload.test.ts index 55f3f74389..e6e5f4de13 100644 --- a/web_src/js/features/comp/EditorUpload.test.ts +++ b/web_src/js/features/comp/EditorUpload.test.ts @@ -1,4 +1,4 @@ -import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts'; +import {pasteAsMarkdownLink, removeAttachmentLinksFromMarkdown} from './EditorUpload.ts'; test('removeAttachmentLinksFromMarkdown', () => { expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b'); @@ -12,3 +12,13 @@ test('removeAttachmentLinksFromMarkdown', () => { expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b'); expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b'); }); + +test('preparePasteAsMarkdownLink', () => { + expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull(); + expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull(); + expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull(); + expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)'); + expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)'); + expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull(); + expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull(); +}); diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts index f6d5731422..bf78f58daf 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -114,21 +114,30 @@ async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, drop export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) { text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), ''); - text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); + text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); return text; } -function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, text: string, isShiftDown: boolean) { +export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null { + const {value, selectionStart, selectionEnd} = textarea; + const selectedText = value.substring(selectionStart, selectionEnd); + const trimmedText = pastedText.trim(); + const beforeSelection = value.substring(0, selectionStart); + const afterSelection = value.substring(selectionEnd); + const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')'); + const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink; + return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null; +} + +function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) { // pasting with "shift" means "paste as original content" in most applications if (isShiftDown) return; // let the browser handle it // when pasting links over selected text, turn it into [text](link) - const {value, selectionStart, selectionEnd} = textarea; - const selectedText = value.substring(selectionStart, selectionEnd); - const trimmedText = text.trim(); - if (selectedText && isUrl(trimmedText) && !isUrl(selectedText)) { + const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText); + if (pastedAsMarkdown) { e.preventDefault(); - replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); + replaceTextareaSelection(textarea, pastedAsMarkdown); } // else, let the browser handle it } diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts index 7bceb636bb..423440129c 100644 --- a/web_src/js/features/comp/LabelEdit.ts +++ b/web_src/js/features/comp/LabelEdit.ts @@ -1,5 +1,6 @@ import {toggleElem} from '../../utils/dom.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; +import {submitFormFetchAction} from '../common-fetch-action.ts'; function nameHasScope(name: string): boolean { return /.*[^/]\/[^/].*/.test(name); @@ -18,6 +19,8 @@ export function initCompLabelEdit(pageSelector: string) { const elExclusiveField = elModal.querySelector('.label-exclusive-input-field'); const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input'); const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning'); + const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field'); + const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input'); const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field'); const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input'); const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input'); @@ -29,6 +32,13 @@ export function initCompLabelEdit(pageSelector: string) { const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive'); toggleElem(elExclusiveWarning, showExclusiveWarning); if (!hasScope) elExclusiveInput.checked = false; + toggleElem(elExclusiveOrderField, elExclusiveInput.checked); + + if (parseInt(elExclusiveOrderInput.value) <= 0) { + elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important'; + } else { + elExclusiveOrderInput.style.color = null; + } }; const showLabelEditModal = (btn:HTMLElement) => { @@ -36,6 +46,7 @@ export function initCompLabelEdit(pageSelector: string) { const form = elModal.querySelector<HTMLFormElement>('form'); elLabelId.value = btn.getAttribute('data-label-id') || ''; elNameInput.value = btn.getAttribute('data-label-name') || ''; + elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0'; elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true'; elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true'; elDescInput.value = btn.getAttribute('data-label-description') || ''; @@ -60,7 +71,8 @@ export function initCompLabelEdit(pageSelector: string) { form.reportValidity(); return false; } - form.submit(); + submitFormFetchAction(form); + return false; }, }).modal('show'); }; diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts index 9fedb3ed24..4b13a2141f 100644 --- a/web_src/js/features/comp/SearchUserBox.ts +++ b/web_src/js/features/comp/SearchUserBox.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {htmlEscape} from '../../utils/html.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {appSubUrl} = window.config; diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts index 5be234629d..2d79fe5029 100644 --- a/web_src/js/features/comp/TextExpander.ts +++ b/web_src/js/features/comp/TextExpander.ts @@ -97,6 +97,7 @@ export function initTextExpander(expander: TextExpanderElement) { li.append(img); const nameSpan = document.createElement('span'); + nameSpan.classList.add('name'); nameSpan.textContent = name; li.append(nameSpan); diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts index d58f6c8246..0fec2a6235 100644 --- a/web_src/js/features/copycontent.ts +++ b/web_src/js/features/copycontent.ts @@ -9,17 +9,17 @@ const {i18n} = window.config; export function initCopyContent() { registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => { if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return; - let content; - let isRasterImage = false; - const link = btn.getAttribute('data-link'); + const rawFileLink = btn.getAttribute('data-raw-file-link'); - // when data-link is present, we perform a fetch. this is either because - // the text to copy is not in the DOM, or it is an image which should be + let content, isRasterImage = false; + + // when "data-raw-link" is present, we perform a fetch. this is either because + // the text to copy is not in the DOM, or it is an image that should be // fetched to copy in full resolution - if (link) { + if (rawFileLink) { btn.classList.add('is-loading', 'loading-icon-2px'); try { - const res = await GET(link, {credentials: 'include', redirect: 'follow'}); + const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'}); const contentType = res.headers.get('content-type'); if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) { diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts index b2ba7651c4..20f7ceb6c3 100644 --- a/web_src/js/features/dropzone.ts +++ b/web_src/js/features/dropzone.ts @@ -1,5 +1,5 @@ import {svg} from '../svg.ts'; -import {htmlEscape} from 'escape-goat'; +import {html} from '../utils/html.ts'; import {clippie} from 'clippie'; import {showTemporaryTooltip} from '../modules/tippy.ts'; import {GET, POST} from '../modules/fetch.ts'; @@ -33,14 +33,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi // Scale down images from HiDPI monitors. This uses the <img> tag because it's the only // method to change image size in Markdown that is supported by all implementations. // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" - fileMarkdown = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(file.name)}" src="attachments/${htmlEscape(file.uuid)}">`; + fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`; } else { // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" fileMarkdown = ``; } } else if (isVideoFile(file)) { - fileMarkdown = `<video src="attachments/${htmlEscape(file.uuid)}" title="${htmlEscape(file.name)}" controls></video>`; + fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`; } return fileMarkdown; } diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts index 135620e51e..69afe491e2 100644 --- a/web_src/js/features/emoji.ts +++ b/web_src/js/features/emoji.ts @@ -1,4 +1,5 @@ import emojis from '../../../assets/emoji.json' with {type: 'json'}; +import {html} from '../utils/html.ts'; const {assetUrlPrefix, customEmojis} = window.config; @@ -24,12 +25,11 @@ for (const key of emojiKeys) { export function emojiHTML(name: string) { let inner; if (Object.hasOwn(customEmojis, name)) { - inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`; + inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`; } else { inner = emojiString(name); } - - return `<span class="emoji" title=":${name}:">${inner}</span>`; + return html`<span class="emoji" title=":${name}:">${inner}</span>`; } // retrieve string for given emoji name diff --git a/web_src/js/features/file-fold.ts b/web_src/js/features/file-fold.ts index 19950d9b9f..74b36c0096 100644 --- a/web_src/js/features/file-fold.ts +++ b/web_src/js/features/file-fold.ts @@ -5,7 +5,7 @@ import {svg} from '../svg.ts'; // The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class. // The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class. // -export function setFileFolding(fileContentBox: HTMLElement, foldArrow: HTMLElement, newFold: boolean) { +export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) { foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18); fileContentBox.setAttribute('data-folded', String(newFold)); if (newFold && fileContentBox.getBoundingClientRect().top < 0) { diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts new file mode 100644 index 0000000000..d803f53c0d --- /dev/null +++ b/web_src/js/features/file-view.ts @@ -0,0 +1,76 @@ +import type {FileRenderPlugin} from '../render/plugin.ts'; +import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts'; +import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; +import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; +import {basename} from '../utils.ts'; + +const plugins: FileRenderPlugin[] = []; + +function initPluginsOnce(): void { + if (plugins.length) return; + plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer()); +} + +function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null { + return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; +} + +function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void { + const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons'); + showElem(toggleButtons); + const displayingRendered = Boolean(renderContainer); + toggleClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist + toggleClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered); + // TODO: if there is only one button, hide it? +} + +async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) { + const elViewRawPrompt = container.querySelector('.file-view-raw-prompt'); + if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container'); + + let rendered = false, errorMsg = ''; + try { + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + if (plugin) { + container.classList.add('is-loading'); + container.setAttribute('data-render-name', plugin.name); // not used yet + await plugin.render(container, rawFileLink); + rendered = true; + } + } catch (e) { + errorMsg = `${e}`; + } finally { + container.classList.remove('is-loading'); + } + + if (rendered) { + elViewRawPrompt.remove(); + return; + } + + // remove all children from the container, and only show the raw file link + container.replaceChildren(elViewRawPrompt); + + if (errorMsg) { + const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`); + elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage); + } +} + +export function initRepoFileView(): void { + registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { + initPluginsOnce(); + const rawFileLink = elFileView.getAttribute('data-raw-file-link'); + const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet + // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + if (!plugin) return; + + const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container'); + showRenderRawFileButton(elFileView, renderContainer); + // maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it + if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType); + }); +} diff --git a/web_src/js/features/install.ts b/web_src/js/features/install.ts index 34df4757f9..ca4bcce881 100644 --- a/web_src/js/features/install.ts +++ b/web_src/js/features/install.ts @@ -104,7 +104,7 @@ function initPreInstall() { } function initPostInstall() { - const el = document.querySelector('#goto-user-login'); + const el = document.querySelector('#goto-after-install'); if (!el) return; const targetUrl = el.getAttribute('href'); diff --git a/web_src/js/features/pull-view-file.ts b/web_src/js/features/pull-view-file.ts index 16ccf00084..1124886238 100644 --- a/web_src/js/features/pull-view-file.ts +++ b/web_src/js/features/pull-view-file.ts @@ -1,4 +1,4 @@ -import {diffTreeStore} from '../modules/stores.ts'; +import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts'; import {setFileFolding} from './file-fold.ts'; import {POST} from '../modules/fetch.ts'; @@ -58,11 +58,8 @@ export function initViewedCheckboxListenerFor() { const fileName = checkbox.getAttribute('name'); - // check if the file is in our difftreestore and if we find it -> change the IsViewed status - const fileInPageData = diffTreeStore().files.find((x: Record<string, any>) => x.Name === fileName); - if (fileInPageData) { - fileInPageData.IsViewed = this.checked; - } + // check if the file is in our diffTreeStore and if we find it -> change the IsViewed status + diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked); // Unfortunately, actual forms cause too many problems, hence another approach is needed const files: Record<string, boolean> = {}; diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index cbd0429c04..8d93fce53f 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -24,6 +24,7 @@ export function initRepositoryActionView() { pushedBy: el.getAttribute('data-locale-runs-pushed-by'), artifactsTitle: el.getAttribute('data-locale-artifacts-title'), areYouSure: el.getAttribute('data-locale-are-you-sure'), + artifactExpired: el.getAttribute('data-locale-artifact-expired'), confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'), showTimeStamps: el.getAttribute('data-locale-show-timestamps'), showLogSeconds: el.getAttribute('data-locale-show-log-seconds'), diff --git a/web_src/js/features/repo-code.ts b/web_src/js/features/repo-code.ts index 207022ca42..bf7fd762b0 100644 --- a/web_src/js/features/repo-code.ts +++ b/web_src/js/features/repo-code.ts @@ -1,6 +1,5 @@ import {svg} from '../svg.ts'; import {createTippy} from '../modules/tippy.ts'; -import {clippie} from 'clippie'; import {toAbsoluteUrl} from '../utils.ts'; import {addDelegatedEventListener} from '../utils/dom.ts'; @@ -43,7 +42,8 @@ function selectRange(range: string): Element { if (!copyPermalink) return; let link = copyPermalink.getAttribute('data-url'); link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`; - copyPermalink.setAttribute('data-url', link); + copyPermalink.setAttribute('data-clipboard-text', link); + copyPermalink.setAttribute('data-clipboard-text-type', 'url'); }; const rangeFields = range ? range.split('-') : []; @@ -110,10 +110,15 @@ function showLineButton() { } export function initRepoCodeView() { - if (!document.querySelector('.code-view .lines-num')) return; + // When viewing a file or blame, there is always a ".file-view" element, + // but the ".code-view" class is only present when viewing the "code" of a file; it is not present when viewing a PDF file. + // Since the ".file-view" will be dynamically reloaded when navigating via the left file tree (eg: view a PDF file, then view a source code file, etc.) + // the "code-view" related event listeners should always be added when the current page contains ".file-view" element. + if (!document.querySelector('.repo-view-container .file-view')) return; + // "file code view" and "blame" pages need this "line number button" feature let selRangeStart: string; - addDelegatedEventListener(document, 'click', '.lines-num span', (el: HTMLElement, e: KeyboardEvent) => { + addDelegatedEventListener(document, 'click', '.code-view .lines-num span', (el: HTMLElement, e: KeyboardEvent) => { if (!selRangeStart || !e.shiftKey) { selRangeStart = el.getAttribute('id'); selectRange(selRangeStart); @@ -125,12 +130,14 @@ export function initRepoCodeView() { showLineButton(); }); + // apply the selected range from the URL hash const onHashChange = () => { if (!window.location.hash) return; + if (!document.querySelector('.code-view .lines-num')) return; const range = window.location.hash.substring(1); const first = selectRange(range); if (first) { - // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing + // set scrollRestoration to 'manual' when there is a hash in the URL, so that the scroll position will not be remembered after refreshing if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual'; first.scrollIntoView({block: 'start'}); showLineButton(); @@ -138,8 +145,4 @@ export function initRepoCodeView() { }; onHashChange(); window.addEventListener('hashchange', onHashChange); - - addDelegatedEventListener(document, 'click', '.copy-line-permalink', (el) => { - clippie(toAbsoluteUrl(el.getAttribute('data-url'))); - }); } diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index e6d1112778..98ec2328ec 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -1,6 +1,6 @@ import {createTippy} from '../modules/tippy.ts'; import {toggleElem} from '../utils/dom.ts'; -import {registerGlobalEventFunc} from '../modules/observer.ts'; +import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts'; export function initRepoEllipsisButton() { registerGlobalEventFunc('click', 'onRepoEllipsisButtonClick', async (el: HTMLInputElement, e: Event) => { @@ -12,15 +12,15 @@ export function initRepoEllipsisButton() { } export function initCommitStatuses() { - for (const element of document.querySelectorAll('[data-tippy="commit-statuses"]')) { - const top = document.querySelector('.repository.file.list') || document.querySelector('.repository.diff'); - - createTippy(element, { - content: element.nextElementSibling, - placement: top ? 'top-start' : 'bottom-start', + registerGlobalInitFunc('initCommitStatuses', (el: HTMLElement) => { + const nextEl = el.nextElementSibling; + if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target'); + createTippy(el, { + content: nextEl, + placement: 'bottom-start', interactive: true, role: 'dialog', theme: 'box-with-header', }); - } + }); } diff --git a/web_src/js/features/repo-common.test.ts b/web_src/js/features/repo-common.test.ts index 009dfc86b1..33a29ecb2c 100644 --- a/web_src/js/features/repo-common.test.ts +++ b/web_src/js/features/repo-common.test.ts @@ -1,7 +1,22 @@ -import {substituteRepoOpenWithUrl} from './repo-common.ts'; +import {sanitizeRepoName, substituteRepoOpenWithUrl} from './repo-common.ts'; test('substituteRepoOpenWithUrl', () => { // For example: "x-github-client://openRepo/https://github.com/go-gitea/gitea" expect(substituteRepoOpenWithUrl('proto://a/{url}', 'https://gitea')).toEqual('proto://a/https://gitea'); expect(substituteRepoOpenWithUrl('proto://a?link={url}', 'https://gitea')).toEqual('proto://a?link=https%3A%2F%2Fgitea'); }); + +test('sanitizeRepoName', () => { + expect(sanitizeRepoName(' a b ')).toEqual('a-b'); + expect(sanitizeRepoName('a-b_c.git ')).toEqual('a-b_c'); + expect(sanitizeRepoName('/x.git/')).toEqual('-x.git-'); + expect(sanitizeRepoName('.profile')).toEqual('.profile'); + expect(sanitizeRepoName('.profile.')).toEqual('.profile'); + expect(sanitizeRepoName('.pro..file')).toEqual('.pro.file'); + + expect(sanitizeRepoName('foo.rss.atom.git.wiki')).toEqual('foo'); + + expect(sanitizeRepoName('.')).toEqual(''); + expect(sanitizeRepoName('..')).toEqual(''); + expect(sanitizeRepoName('-')).toEqual(''); +}); diff --git a/web_src/js/features/repo-common.ts b/web_src/js/features/repo-common.ts index 4362a2c713..ebb6881c67 100644 --- a/web_src/js/features/repo-common.ts +++ b/web_src/js/features/repo-common.ts @@ -159,3 +159,19 @@ export async function updateIssuesMeta(url: string, action: string, issue_ids: s console.error(error); } } + +export function sanitizeRepoName(name: string): string { + name = name.trim().replace(/[^-.\w]/g, '-'); + for (let lastName = ''; lastName !== name;) { + lastName = name; + name = name.replace(/\.+$/g, ''); + name = name.replace(/\.{2,}/g, '.'); + for (const ext of ['.git', '.wiki', '.rss', '.atom']) { + if (name.endsWith(ext)) { + name = name.substring(0, name.length - ext.length); + } + } + } + if (['.', '..', '-'].includes(name)) name = ''; + return name; +} diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts index 0f77508f70..f3ca13460c 100644 --- a/web_src/js/features/repo-editor.ts +++ b/web_src/js/features/repo-editor.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; import {createCodeEditor} from './codeeditor.ts'; import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts'; import {attachRefIssueContextPopup} from './contextpopup.ts'; @@ -7,6 +7,7 @@ import {initDropzone} from './dropzone.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {submitFormFetchAction} from './common-fetch-action.ts'; function initEditPreviewTab(elForm: HTMLFormElement) { const elTabMenu = elForm.querySelector('.repo-editor-menu'); @@ -86,10 +87,10 @@ export function initRepoEditor() { if (i < parts.length - 1) { if (trimValue.length) { const linkElement = createElementFromHTML( - `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`, + html`<span class="section"><a href="#">${value}</a></span>`, ); const dividerElement = createElementFromHTML( - `<div class="breadcrumb-divider">/</div>`, + html`<div class="breadcrumb-divider">/</div>`, ); links.push(linkElement); dividers.push(dividerElement); @@ -112,7 +113,7 @@ export function initRepoEditor() { if (!warningDiv) { warningDiv = document.createElement('div'); warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related'); - warningDiv.innerHTML = '<p>File path contains leading or trailing whitespace.</p>'; + warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`; // Add display 'block' because display is set to 'none' in formantic\build\semantic.css warningDiv.style.display = 'block'; const inputContainer = document.querySelector('.repo-editor-header'); @@ -141,38 +142,36 @@ export function initRepoEditor() { } }); + const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form'); + // on the upload page, there is no editor(textarea) const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area'); if (!editArea) return; - const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form'); + // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage + // to enable or disable the commit button + const commitButton = document.querySelector<HTMLButtonElement>('#commit-button'); + const dirtyFileClass = 'dirty-file'; + + const syncCommitButtonState = () => { + const dirty = elForm.classList.contains(dirtyFileClass); + commitButton.disabled = !dirty; + }; + // Registering a custom listener for the file path and the file content + // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added + applyAreYouSure(elForm, { + silent: true, + dirtyClass: dirtyFileClass, + fieldSelector: ':input:not(.commit-form-wrapper :input)', + change: syncCommitButtonState, + }); + syncCommitButtonState(); // disable the "commit" button when no content changes + initEditPreviewTab(elForm); (async () => { const editor = await createCodeEditor(editArea, filenameInput); - // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage - // to enable or disable the commit button - const commitButton = document.querySelector<HTMLButtonElement>('#commit-button'); - const dirtyFileClass = 'dirty-file'; - - // Disabling the button at the start - if (document.querySelector<HTMLInputElement>('input[name="page_has_posted"]').value !== 'true') { - commitButton.disabled = true; - } - - // Registering a custom listener for the file path and the file content - // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added - applyAreYouSure(elForm, { - silent: true, - dirtyClass: dirtyFileClass, - fieldSelector: ':input:not(.commit-form-wrapper :input)', - change($form: any) { - const dirty = $form[0]?.classList.contains(dirtyFileClass); - commitButton.disabled = !dirty; - }, - }); - // Update the editor from query params, if available, // only after the dirtyFileClass initialization const params = new URLSearchParams(window.location.search); @@ -181,7 +180,7 @@ export function initRepoEditor() { editor.setValue(value); } - commitButton?.addEventListener('click', async (e) => { + commitButton.addEventListener('click', async (e) => { // A modal which asks if an empty file should be committed if (!editArea.value) { e.preventDefault(); @@ -190,14 +189,15 @@ export function initRepoEditor() { content: elForm.getAttribute('data-text-empty-confirm-content'), })) { ignoreAreYouSure(elForm); - elForm.submit(); + submitFormFetchAction(elForm); } } }); })(); } -export function renderPreviewPanelContent(previewPanel: Element, content: string) { - previewPanel.innerHTML = `<div class="render-content markup">${content}</div>`; +export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) { + // the content is from the server, so it is safe to use innerHTML + previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`; attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue')); } diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts index b3de91c3bd..e89e5a787a 100644 --- a/web_src/js/features/repo-issue-edit.ts +++ b/web_src/js/features/repo-issue-edit.ts @@ -132,7 +132,7 @@ async function tryOnQuoteReply(e: Event) { const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector<HTMLElement>('.render-content.markup'); let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote); if (!contentToQuote) contentToQuote = targetRawToQuote.textContent; - const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`; + const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n\n`; let editor; if (clickTarget.classList.contains('quote-reply-diff')) { diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts index 01d4bb6f78..762fbf51bb 100644 --- a/web_src/js/features/repo-issue-list.ts +++ b/web_src/js/features/repo-issue-list.ts @@ -1,6 +1,6 @@ import {updateIssuesMeta} from './repo-common.ts'; -import {toggleElem, isElemHidden, queryElems} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; import {showErrorToast} from '../modules/toast.ts'; import {createSortable} from '../modules/sortable.ts'; @@ -33,8 +33,8 @@ function initRepoIssueListCheckboxes() { toggleElem('#issue-filters', !anyChecked); toggleElem('#issue-actions', anyChecked); // there are two panels but only one select-all checkbox, so move the checkbox to the visible panel - const panels = document.querySelectorAll('#issue-filters, #issue-actions'); - const visiblePanel = Array.from(panels).find((el) => !isElemHidden(el)); + const panels = document.querySelectorAll<HTMLElement>('#issue-filters, #issue-actions'); + const visiblePanel = Array.from(panels).find((el) => isElemVisible(el)); const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left'); toolbarLeft.prepend(issueSelectAll); }; @@ -138,10 +138,10 @@ function initDropdownUserRemoteSearch(el: Element) { // the content is provided by backend IssuePosters handler processedResults.length = 0; for (const item of resp.results) { - let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`; - if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`; + let nameHtml = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${item.username}</span>`; + if (item.full_name) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`; if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username; - processedResults.push({value: item.username, name: html}); + processedResults.push({value: item.username, name: nameHtml}); } resp.results = processedResults; return resp; diff --git a/web_src/js/features/repo-issue-pr-form.ts b/web_src/js/features/repo-issue-pr-form.ts deleted file mode 100644 index 94a2857340..0000000000 --- a/web_src/js/features/repo-issue-pr-form.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {createApp} from 'vue'; -import PullRequestMergeForm from '../components/PullRequestMergeForm.vue'; - -export function initRepoPullRequestMergeForm() { - const el = document.querySelector('#pull-request-merge-form'); - if (!el) return; - - const view = createApp(PullRequestMergeForm); - view.mount(el); -} diff --git a/web_src/js/features/repo-issue-pr-status.ts b/web_src/js/features/repo-issue-pr-status.ts deleted file mode 100644 index 8426b389f0..0000000000 --- a/web_src/js/features/repo-issue-pr-status.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function initRepoPullRequestCommitStatus() { - for (const btn of document.querySelectorAll('.commit-status-hide-checks')) { - const panel = btn.closest('.commit-status-panel'); - const list = panel.querySelector<HTMLElement>('.commit-status-list'); - btn.addEventListener('click', () => { - list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle - btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all'); - }); - } -} diff --git a/web_src/js/features/repo-issue-pull.ts b/web_src/js/features/repo-issue-pull.ts new file mode 100644 index 0000000000..c415dad08f --- /dev/null +++ b/web_src/js/features/repo-issue-pull.ts @@ -0,0 +1,133 @@ +import {createApp} from 'vue'; +import PullRequestMergeForm from '../components/PullRequestMergeForm.vue'; +import {GET, POST} from '../modules/fetch.ts'; +import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; + +function initRepoPullRequestUpdate(el: HTMLElement) { + const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base'); + if (!prUpdateButtonContainer) return; + + const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button'); + const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown'); + prUpdateButton.addEventListener('click', async function (e) { + e.preventDefault(); + const redirect = this.getAttribute('data-redirect'); + this.classList.add('is-loading'); + let response: Response; + try { + response = await POST(this.getAttribute('data-do')); + } catch (error) { + console.error(error); + } finally { + this.classList.remove('is-loading'); + } + let data: Record<string, any>; + try { + data = await response?.json(); // the response is probably not a JSON + } catch (error) { + console.error(error); + } + if (data?.redirect) { + window.location.href = data.redirect; + } else if (redirect) { + window.location.href = redirect; + } else { + window.location.reload(); + } + }); + + fomanticQuery(prUpdateDropdown).dropdown({ + onChange(_text: string, _value: string, $choice: any) { + const choiceEl = $choice[0]; + const url = choiceEl.getAttribute('data-do'); + if (url) { + const buttonText = prUpdateButton.querySelector('.button-text'); + if (buttonText) { + buttonText.textContent = choiceEl.textContent; + } + prUpdateButton.setAttribute('data-do', url); + } + }, + }); +} + +function initRepoPullRequestCommitStatus(el: HTMLElement) { + for (const btn of el.querySelectorAll('.commit-status-hide-checks')) { + const panel = btn.closest('.commit-status-panel'); + const list = panel.querySelector<HTMLElement>('.commit-status-list'); + btn.addEventListener('click', () => { + list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle + btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all'); + }); + } +} + +function initRepoPullRequestMergeForm(box: HTMLElement) { + const el = box.querySelector('#pull-request-merge-form'); + if (!el) return; + + const view = createApp(PullRequestMergeForm); + view.mount(el); +} + +function executeScripts(elem: HTMLElement) { + for (const oldScript of elem.querySelectorAll('script')) { + // TODO: that's the only way to load the data for the merge form. In the future + // we need to completely decouple the page data and embedded script + // eslint-disable-next-line github/no-dynamic-script-tag + const newScript = document.createElement('script'); + for (const attr of oldScript.attributes) { + if (attr.name === 'type' && attr.value === 'module') continue; + newScript.setAttribute(attr.name, attr.value); + } + newScript.text = oldScript.text; + document.body.append(newScript); + } +} + +export function initRepoPullMergeBox(el: HTMLElement) { + initRepoPullRequestCommitStatus(el); + initRepoPullRequestUpdate(el); + initRepoPullRequestMergeForm(el); + + const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval'); + if (!reloadingIntervalValue) return; + + const reloadingInterval = parseInt(reloadingIntervalValue); + const pullLink = el.getAttribute('data-pull-link'); + let timerId: number; + + let reloadMergeBox: () => Promise<void>; + const stopReloading = () => { + if (!timerId) return; + clearTimeout(timerId); + timerId = null; + }; + const startReloading = () => { + if (timerId) return; + setTimeout(reloadMergeBox, reloadingInterval); + }; + const onVisibilityChange = () => { + if (document.hidden) { + stopReloading(); + } else { + startReloading(); + } + }; + reloadMergeBox = async () => { + const resp = await GET(`${pullLink}/merge_box`); + stopReloading(); + if (!resp.ok) { + startReloading(); + return; + } + document.removeEventListener('visibilitychange', onVisibilityChange); + const newElem = createElementFromHTML(await resp.text()); + executeScripts(newElem); + el.replaceWith(newElem); + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + startReloading(); +} diff --git a/web_src/js/features/repo-issue-sidebar-combolist.ts b/web_src/js/features/repo-issue-sidebar-combolist.ts index c30d4fe50d..f25c0a77c6 100644 --- a/web_src/js/features/repo-issue-sidebar-combolist.ts +++ b/web_src/js/features/repo-issue-sidebar-combolist.ts @@ -1,6 +1,6 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; import {POST} from '../modules/fetch.ts'; -import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; +import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; // if there are draft comments, confirm before reloading, to avoid losing comments function issueSidebarReloadConfirmDraftComment() { @@ -22,7 +22,7 @@ function issueSidebarReloadConfirmDraftComment() { window.location.reload(); } -class IssueSidebarComboList { +export class IssueSidebarComboList { updateUrl: string; updateAlgo: string; selectionMode: string; @@ -95,9 +95,7 @@ class IssueSidebarComboList { } } - async onItemClick(e: Event) { - const elItem = (e.target as HTMLElement).closest('.item'); - if (!elItem) return; + async onItemClick(elItem: HTMLElement, e: Event) { e.preventDefault(); if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return; @@ -146,16 +144,13 @@ class IssueSidebarComboList { } this.initialValues = this.collectCheckedValues(); - this.elDropdown.addEventListener('click', (e) => this.onItemClick(e)); + addDelegatedEventListener(this.elDropdown, 'click', '.item', (el, e) => this.onItemClick(el, e)); fomanticQuery(this.elDropdown).dropdown('setting', { action: 'nothing', // do not hide the menu if user presses Enter fullTextSearch: 'exact', + hideDividers: 'empty', onHide: () => this.onHide(), }); } } - -export function initIssueSidebarComboList(container: HTMLElement) { - new IssueSidebarComboList(container).init(); -} diff --git a/web_src/js/features/repo-issue-sidebar.ts b/web_src/js/features/repo-issue-sidebar.ts index f84bed127f..290e1ae000 100644 --- a/web_src/js/features/repo-issue-sidebar.ts +++ b/web_src/js/features/repo-issue-sidebar.ts @@ -1,6 +1,6 @@ import {POST} from '../modules/fetch.ts'; import {queryElems, toggleElem} from '../utils/dom.ts'; -import {initIssueSidebarComboList} from './repo-issue-sidebar-combolist.ts'; +import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts'; function initBranchSelector() { // TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" @@ -48,5 +48,5 @@ export function initRepoIssueSidebar() { initRepoIssueDue(); // init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions - queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el)); + queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init()); } diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index ed79cbfe50..49e8fc40a2 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlEscape} from '../utils/html.ts'; import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts'; import { addDelegatedEventListener, @@ -17,6 +17,7 @@ import {showErrorToast} from '../modules/toast.ts'; import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; const {appSubUrl} = window.config; @@ -45,8 +46,7 @@ export function initRepoIssueSidebarDependency() { if (String(issue.id) === currIssueId) continue; filteredResponse.results.push({ value: issue.id, - name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div> -<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`, + name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`, }); } return filteredResponse; @@ -197,54 +197,6 @@ export function initRepoIssueCodeCommentCancel() { }); } -export function initRepoPullRequestUpdate() { - const prUpdateButtonContainer = document.querySelector('#update-pr-branch-with-base'); - if (!prUpdateButtonContainer) return; - - const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button'); - const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown'); - prUpdateButton.addEventListener('click', async function (e) { - e.preventDefault(); - const redirect = this.getAttribute('data-redirect'); - this.classList.add('is-loading'); - let response: Response; - try { - response = await POST(this.getAttribute('data-do')); - } catch (error) { - console.error(error); - } finally { - this.classList.remove('is-loading'); - } - let data: Record<string, any>; - try { - data = await response?.json(); // the response is probably not a JSON - } catch (error) { - console.error(error); - } - if (data?.redirect) { - window.location.href = data.redirect; - } else if (redirect) { - window.location.href = redirect; - } else { - window.location.reload(); - } - }); - - fomanticQuery(prUpdateDropdown).dropdown({ - onChange(_text: string, _value: string, $choice: any) { - const choiceEl = $choice[0]; - const url = choiceEl.getAttribute('data-do'); - if (url) { - const buttonText = prUpdateButton.querySelector('.button-text'); - if (buttonText) { - buttonText.textContent = choiceEl.textContent; - } - prUpdateButton.setAttribute('data-do', url); - } - }, - }); -} - export function initRepoPullRequestAllowMaintainerEdit() { const wrapper = document.querySelector('#allow-edits-from-maintainers'); if (!wrapper) return; @@ -464,25 +416,20 @@ export function initRepoIssueWipNewTitle() { export function initRepoIssueWipToggle() { // Toggle WIP for existing PR - queryElems(document, '.toggle-wip', (el) => el.addEventListener('click', async (e) => { + registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => { e.preventDefault(); - const toggleWip = el; const title = toggleWip.getAttribute('data-title'); const wipPrefix = toggleWip.getAttribute('data-wip-prefix'); const updateUrl = toggleWip.getAttribute('data-update-url'); - try { - const params = new URLSearchParams(); - params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`); - - const response = await POST(updateUrl, {data: params}); - if (!response.ok) { - throw new Error('Failed to toggle WIP status'); - } - window.location.reload(); - } catch (error) { - console.error(error); + const params = new URLSearchParams(); + params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`); + const response = await POST(updateUrl, {data: params}); + if (!response.ok) { + showErrorToast(`Failed to toggle 'work in progress' status`); + return; } + window.location.reload(); })); } diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index 0ff6feba2d..249d181b25 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -4,7 +4,6 @@ import { initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, initRepoIssueComments, initRepoIssueReferenceIssue, initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle, - initRepoPullRequestUpdate, } from './repo-issue.ts'; import {initUnicodeEscapeButton} from './repo-unicode-escape.ts'; import {initRepoCloneButtons} from './repo-common.ts'; @@ -12,14 +11,13 @@ import {initCitationFileCopyContent} from './citation.ts'; import {initCompLabelEdit} from './comp/LabelEdit.ts'; import {initCompReactionSelector} from './comp/ReactionSelector.ts'; import {initRepoSettings} from './repo-settings.ts'; -import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.ts'; -import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.ts'; import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts'; import {initRepoIssueCommentEdit} from './repo-issue-edit.ts'; import {initRepoMilestone} from './repo-milestone.ts'; import {initRepoNew} from './repo-new.ts'; import {createApp} from 'vue'; import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue'; +import {initRepoPullMergeBox} from './repo-issue-pull.ts'; function initRepoBranchTagSelector() { registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => { @@ -69,11 +67,9 @@ export function initRepository() { initRepoIssueCommentDelete(); initRepoIssueCodeCommentCancel(); - initRepoPullRequestUpdate(); initCompReactionSelector(); - initRepoPullRequestMergeForm(); - initRepoPullRequestCommitStatus(); + registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox); } initUnicodeEscapeButton(); diff --git a/web_src/js/features/repo-migration.ts b/web_src/js/features/repo-migration.ts index fb9c822f98..4914e47267 100644 --- a/web_src/js/features/repo-migration.ts +++ b/web_src/js/features/repo-migration.ts @@ -1,4 +1,5 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.ts'; +import {sanitizeRepoName} from './repo-common.ts'; const service = document.querySelector<HTMLInputElement>('#service_type'); const user = document.querySelector<HTMLInputElement>('#auth_username'); @@ -25,13 +26,19 @@ export function initRepoMigration() { }); lfs?.addEventListener('change', setLFSSettingsVisibility); - const cloneAddr = document.querySelector<HTMLInputElement>('#clone_addr'); - cloneAddr?.addEventListener('change', () => { - const repoName = document.querySelector<HTMLInputElement>('#repo_name'); - if (cloneAddr.value && !repoName?.value) { // Only modify if repo_name input is blank - repoName.value = /^(.*\/)?((.+?)(\.git)?)$/.exec(cloneAddr.value)[3]; - } - }); + const elCloneAddr = document.querySelector<HTMLInputElement>('#clone_addr'); + const elRepoName = document.querySelector<HTMLInputElement>('#repo_name'); + if (elCloneAddr && elRepoName) { + let repoNameChanged = false; + elRepoName.addEventListener('input', () => {repoNameChanged = true}); + elCloneAddr.addEventListener('input', () => { + if (repoNameChanged) return; + let repoNameFromUrl = elCloneAddr.value.split(/[?#]/)[0]; + repoNameFromUrl = /^(.*\/)?((.+?)\/?)$/.exec(repoNameFromUrl)[3]; + repoNameFromUrl = repoNameFromUrl.split(/[?#]/)[0]; + elRepoName.value = sanitizeRepoName(repoNameFromUrl); + }); + } } function checkAuth() { diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts index f2c5eba62c..e2aa13f490 100644 --- a/web_src/js/features/repo-new.ts +++ b/web_src/js/features/repo-new.ts @@ -1,11 +1,14 @@ -import {hideElem, showElem, toggleElem} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts'; +import {htmlEscape} from '../utils/html.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {sanitizeRepoName} from './repo-common.ts'; const {appSubUrl} = window.config; function initRepoNewTemplateSearch(form: HTMLFormElement) { - const inputRepoOwnerUid = form.querySelector<HTMLInputElement>('#uid'); + const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(form, '.ui.primary.button'); + const elCreateRepoErrorMessage = form.querySelector('#create-repo-error-message'); + const elRepoOwnerDropdown = form.querySelector('#repo_owner_dropdown'); const elRepoTemplateDropdown = form.querySelector<HTMLInputElement>('#repo_template_search'); const inputRepoTemplate = form.querySelector<HTMLInputElement>('#repo_template'); const elTemplateUnits = form.querySelector('#template_units'); @@ -18,11 +21,23 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) { inputRepoTemplate.addEventListener('change', checkTemplate); checkTemplate(); - const $dropdown = fomanticQuery(elRepoTemplateDropdown); + const $repoOwnerDropdown = fomanticQuery(elRepoOwnerDropdown); + const $repoTemplateDropdown = fomanticQuery(elRepoTemplateDropdown); const onChangeOwner = function () { - $dropdown.dropdown('setting', { + const ownerId = $repoOwnerDropdown.dropdown('get value'); + const $ownerItem = $repoOwnerDropdown.dropdown('get item', ownerId); + hideElem(elCreateRepoErrorMessage); + elSubmitButton.disabled = false; + if ($ownerItem?.length) { + const elOwnerItem = $ownerItem[0]; + elCreateRepoErrorMessage.textContent = elOwnerItem.getAttribute('data-create-repo-disallowed-prompt') ?? ''; + const hasError = Boolean(elCreateRepoErrorMessage.textContent); + toggleElem(elCreateRepoErrorMessage, hasError); + elSubmitButton.disabled = hasError; + } + $repoTemplateDropdown.dropdown('setting', { apiSettings: { - url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${inputRepoOwnerUid.value}`, + url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${ownerId}`, onResponse(response: any) { const results = []; results.push({name: '', value: ''}); // empty item means not using template @@ -32,14 +47,14 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) { value: String(tmplRepo.repository.id), }); } - $dropdown.fomanticExt.onResponseKeepSelectedItem($dropdown, inputRepoTemplate.value); + $repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value); return {results}; }, cache: false, }, }); }; - inputRepoOwnerUid.addEventListener('change', onChangeOwner); + $repoOwnerDropdown.dropdown('setting', 'onChange', onChangeOwner); onChangeOwner(); } @@ -74,6 +89,10 @@ export function initRepoNew() { } }; inputRepoName.addEventListener('input', updateUiRepoName); + inputRepoName.addEventListener('change', () => { + inputRepoName.value = sanitizeRepoName(inputRepoName.value); + updateUiRepoName(); + }); updateUiRepoName(); initRepoNewTemplateSearch(form); diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts index 11f5c19c8d..ad0feb6101 100644 --- a/web_src/js/features/repo-projects.ts +++ b/web_src/js/features/repo-projects.ts @@ -2,8 +2,9 @@ import {contrastColor} from '../utils/color.ts'; import {createSortable} from '../modules/sortable.ts'; import {POST, request} from '../modules/fetch.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; -import {queryElemChildren, queryElems} from '../utils/dom.ts'; +import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; import type {SortableEvent} from 'sortablejs'; +import {toggleFullScreen} from '../utils.ts'; function updateIssueCount(card: HTMLElement): void { const parent = card.parentElement; @@ -34,8 +35,8 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi } async function initRepoProjectSortable(): Promise<void> { - // the HTML layout is: #project-board > .board > .project-column .cards > .issue-card - const mainBoard = document.querySelector('#project-board > .board.sortable'); + // the HTML layout is: #project-board.board > .project-column .cards > .issue-card + const mainBoard = document.querySelector('#project-board'); let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column'); createSortable(mainBoard, { group: 'project-column', @@ -113,7 +114,6 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void { window.location.reload(); // newly added column, need to reload the page return; } - fomanticQuery(elModal).modal('hide'); // update the newly saved column title and color in the project board (to avoid reload) const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`); @@ -133,13 +133,32 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void { elBoardColumn.style.removeProperty('color'); queryElemChildren<HTMLElement>(elBoardColumn, '.divider', (divider) => divider.style.removeProperty('color')); } + + fomanticQuery(elModal).modal('hide'); } finally { elForm.classList.remove('is-loading'); } }); } +function initRepoProjectToggleFullScreen(): void { + const enterFullscreenBtn = document.querySelector('.screen-full'); + const exitFullscreenBtn = document.querySelector('.screen-normal'); + if (!enterFullscreenBtn || !exitFullscreenBtn) return; + + const toggleFullscreenState = (isFullScreen: boolean) => { + toggleFullScreen('.projects-view', isFullScreen); + toggleElem(enterFullscreenBtn, !isFullScreen); + toggleElem(exitFullscreenBtn, isFullScreen); + }; + + enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true)); + exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false)); +} + export function initRepoProject(): void { + initRepoProjectToggleFullScreen(); + const writableProjectBoard = document.querySelector('#project-board[data-project-borad-writable="true"]'); if (!writableProjectBoard) return; diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts index 80f897069e..be1821664f 100644 --- a/web_src/js/features/repo-settings.ts +++ b/web_src/js/features/repo-settings.ts @@ -1,8 +1,7 @@ import {minimatch} from 'minimatch'; import {createMonaco} from './codeeditor.ts'; -import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; +import {onInputDebounce, queryElems, toggleClass, toggleElem} from '../utils/dom.ts'; import {POST} from '../modules/fetch.ts'; -import {initAvatarUploaderWithCropper} from './comp/Cropper.ts'; import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; @@ -125,22 +124,14 @@ function initRepoSettingsOptions() { const pageContent = document.querySelector('.page-content.repository.settings.options'); if (!pageContent) return; - const toggleClass = (elems: NodeListOf<Element>, className: string, value: boolean) => { - for (const el of elems) el.classList.toggle(className, value); - }; - // Enable or select internal/external wiki system and issue tracker. queryElems<HTMLInputElement>(pageContent, '.enable-system', (el) => el.addEventListener('change', () => { - const elTargets = document.querySelectorAll(el.getAttribute('data-target')); - const elContexts = document.querySelectorAll(el.getAttribute('data-context')); - toggleClass(elTargets, 'disabled', !el.checked); - toggleClass(elContexts, 'disabled', el.checked); + toggleClass(el.getAttribute('data-target'), 'disabled', !el.checked); + toggleClass(el.getAttribute('data-context'), 'disabled', el.checked); })); queryElems<HTMLInputElement>(pageContent, '.enable-system-radio', (el) => el.addEventListener('change', () => { - const elTargets = document.querySelectorAll(el.getAttribute('data-target')); - const elContexts = document.querySelectorAll(el.getAttribute('data-context')); - toggleClass(elTargets, 'disabled', el.value === 'false'); - toggleClass(elContexts, 'disabled', el.value === 'true'); + toggleClass(el.getAttribute('data-target'), 'disabled', el.value === 'false'); + toggleClass(el.getAttribute('data-context'), 'disabled', el.value === 'true'); })); queryElems<HTMLInputElement>(pageContent, '.js-tracker-issue-style', (el) => el.addEventListener('change', () => { @@ -157,6 +148,4 @@ export function initRepoSettings() { initRepoSettingsSearchTeamBox(); initRepoSettingsGitHook(); initRepoSettingsBranchesDrag(); - - queryElems(document, '.avatar-file-with-cropper', initAvatarUploaderWithCropper); } diff --git a/web_src/js/features/repo-wiki.ts b/web_src/js/features/repo-wiki.ts index f94d3ef3d1..6ae0947077 100644 --- a/web_src/js/features/repo-wiki.ts +++ b/web_src/js/features/repo-wiki.ts @@ -2,6 +2,7 @@ import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMar import {fomanticMobileScreen} from '../modules/fomantic.ts'; import {POST} from '../modules/fetch.ts'; import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; +import {html, htmlRaw} from '../utils/html.ts'; async function initRepoWikiFormEditor() { const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea'); @@ -30,7 +31,7 @@ async function initRepoWikiFormEditor() { const response = await POST(editor.previewUrl, {data: formData}); const data = await response.text(); lastContent = newContent; - previewTarget.innerHTML = `<div class="render-content markup ui segment">${data}</div>`; + previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`; } catch (error) { console.error('Error rendering preview:', error); } finally { diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index a5cd5ae7c4..07f9c435b8 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -134,7 +134,7 @@ function updateStopwatchData(data: any) { const {repo_owner_name, repo_name, issue_index, seconds} = watch; const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`; document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl); - document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/toggle`); + document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`); document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`); const stopwatchIssue = document.querySelector('.stopwatch-issue'); if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`; diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts index de1c3e97cd..43c21ebe6d 100644 --- a/web_src/js/features/tribute.ts +++ b/web_src/js/features/tribute.ts @@ -1,5 +1,5 @@ import {emojiKeys, emojiHTML, emojiString} from './emoji.ts'; -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; type TributeItem = Record<string, any>; @@ -26,17 +26,18 @@ export async function attachTribute(element: HTMLElement) { return emojiString(item.original); }, menuItemTemplate: (item: TributeItem) => { - return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`; + return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`; }, }, { // mentions values: window.config.mentionValues ?? [], requireLeadingSpace: true, menuItemTemplate: (item: TributeItem) => { - return ` + const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : ''; + return html` <div class="tribute-item"> - <img src="${htmlEscape(item.original.avatar)}" width="21" height="21"/> - <span class="name">${htmlEscape(item.original.name)}</span> - ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''} + <img alt src="${item.original.avatar}" width="21" height="21"/> + <span class="name">${item.original.name}</span> + ${htmlRaw(fullNameHtml)} </div> `; }, diff --git a/web_src/js/features/user-settings.ts b/web_src/js/features/user-settings.ts index 21d20e676f..6fbb56e540 100644 --- a/web_src/js/features/user-settings.ts +++ b/web_src/js/features/user-settings.ts @@ -1,11 +1,8 @@ -import {hideElem, queryElems, showElem} from '../utils/dom.ts'; -import {initAvatarUploaderWithCropper} from './comp/Cropper.ts'; +import {hideElem, showElem} from '../utils/dom.ts'; export function initUserSettings() { if (!document.querySelector('.user.settings.profile')) return; - queryElems(document, '.avatar-file-with-cropper', initAvatarUploaderWithCropper); - const usernameInput = document.querySelector<HTMLInputElement>('#username'); if (!usernameInput) return; usernameInput.addEventListener('input', function () { diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts index 9e97ec0492..e4b540122d 100644 --- a/web_src/js/globals.d.ts +++ b/web_src/js/globals.d.ts @@ -25,11 +25,6 @@ declare module 'htmx.org/dist/htmx.esm.js' { export default value; } -declare module 'uint8-to-base64' { - export function encode(arrayBuffer: Uint8Array): string; - export function decode(base64str: string): Uint8Array; -} - declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' { const value = await import('swagger-ui-dist'); export default value.SwaggerUIBundle; diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 839a160168..347aad2709 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -19,7 +19,7 @@ import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; import {initStopwatch} from './features/stopwatch.ts'; import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initMarkupContent} from './markup/content.ts'; -import {initPdfViewer} from './render/pdf.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'; @@ -60,7 +60,7 @@ 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 {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts'; +import {initFootLanguageMenu, initGlobalAvatarUploader, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} 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'; @@ -72,6 +72,7 @@ initSubmitEventPolyfill(); onDomReady(() => { const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ + initGlobalAvatarUploader, initGlobalDropdown, initGlobalTabularMenu, initGlobalFetchAction, @@ -158,10 +159,11 @@ onDomReady(() => { initUserAuthWebAuthnRegister, initUserSettings, initRepoDiffView, - initPdfViewer, initColorPickers, initOAuth2SettingsDisableCheckbox, + + initRepoFileView, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/markup/anchors.ts b/web_src/js/markup/anchors.ts index 483d72bd5b..a0d49911fe 100644 --- a/web_src/js/markup/anchors.ts +++ b/web_src/js/markup/anchors.ts @@ -5,21 +5,24 @@ const removePrefix = (str: string): string => str.replace(/^user-content-/, ''); const hasPrefix = (str: string): boolean => str.startsWith('user-content-'); // scroll to anchor while respecting the `user-content` prefix that exists on the target -function scrollToAnchor(encodedId: string): void { - if (!encodedId) return; - const id = decodeURIComponent(encodedId); - const prefixedId = addPrefix(id); - let el = document.querySelector(`#${prefixedId}`); +function scrollToAnchor(encodedId?: string): void { + // FIXME: need to rewrite this function with new a better markup anchor generation logic, too many tricks here + let elemId: string; + try { + elemId = decodeURIComponent(encodedId ?? ''); + } catch {} // ignore the errors, since the "encodedId" is from user's input + if (!elemId) return; + + const prefixedId = addPrefix(elemId); + // eslint-disable-next-line unicorn/prefer-query-selector + let el = document.getElementById(prefixedId); // check for matching user-generated `a[name]` - if (!el) { - el = document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`); - } + el = el ?? document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`); // compat for links with old 'user-content-' prefixed hashes - if (!el && hasPrefix(id)) { - return document.querySelector(`#${id}`)?.scrollIntoView(); - } + // eslint-disable-next-line unicorn/prefer-query-selector + el = (!el && hasPrefix(elemId)) ? document.getElementById(elemId) : el; el?.scrollIntoView(); } diff --git a/web_src/js/markup/asciicast.ts b/web_src/js/markup/asciicast.ts index 22dbff2d46..125bba447b 100644 --- a/web_src/js/markup/asciicast.ts +++ b/web_src/js/markup/asciicast.ts @@ -1,16 +1,17 @@ -export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> { - const el = elMarkup.querySelector('.asciinema-player-container'); - if (!el) return; +import {queryElems} from '../utils/dom.ts'; - const [player] = await Promise.all([ - // @ts-expect-error: module exports no types - import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), - import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), - ]); +export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> { + queryElems(elMarkup, '.asciinema-player-container', async (el) => { + const [player] = await Promise.all([ + // @ts-expect-error: module exports no types + import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), + import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), + ]); - player.create(el.getAttribute('data-asciinema-player-src'), el, { - // poster (a preview frame) to display until the playback is started. - // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. - poster: 'npt:1:0:0', + player.create(el.getAttribute('data-asciinema-player-src'), el, { + // poster (a preview frame) to display until the playback is started. + // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. + poster: 'npt:1:0:0', + }); }); } diff --git a/web_src/js/markup/codecopy.ts b/web_src/js/markup/codecopy.ts index 4430256848..b37aa3a236 100644 --- a/web_src/js/markup/codecopy.ts +++ b/web_src/js/markup/codecopy.ts @@ -1,4 +1,5 @@ import {svg} from '../svg.ts'; +import {queryElems} from '../utils/dom.ts'; export function makeCodeCopyButton(): HTMLButtonElement { const button = document.createElement('button'); @@ -8,11 +9,14 @@ export function makeCodeCopyButton(): HTMLButtonElement { } export function initMarkupCodeCopy(elMarkup: HTMLElement): void { - const el = elMarkup.querySelector('.code-block code'); // .markup .code-block code - if (!el || !el.textContent) return; - - const btn = makeCodeCopyButton(); - // remove final trailing newline introduced during HTML rendering - btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, '')); - el.after(btn); + // .markup .code-block code + queryElems(elMarkup, '.code-block code', (el) => { + if (!el.textContent) return; + const btn = makeCodeCopyButton(); + // remove final trailing newline introduced during HTML rendering + btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, '')); + // we only want to use `.code-block-container` if it exists, no matter `.code-block` exists or not. + const btnContainer = el.closest('.code-block-container') ?? el.closest('.code-block'); + btnContainer.append(btn); + }); } diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts index 8c2d2f8c86..5866d0d259 100644 --- a/web_src/js/markup/html2markdown.ts +++ b/web_src/js/markup/html2markdown.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; type Processor = (el: HTMLElement) => string | HTMLElement | void; @@ -38,10 +38,10 @@ function prepareProcessors(ctx:ProcessorContext): Processors { IMG(el: HTMLElement) { const alt = el.getAttribute('alt') || 'image'; const src = el.getAttribute('src'); - const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : ''; - const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : ''; + const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : ''; + const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : ''; if (widthAttr || heightAttr) { - return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`; + return html`<img alt="${alt}"${widthAttr}${heightAttr} src="${src}">`; } return ``; }, diff --git a/web_src/js/markup/math.ts b/web_src/js/markup/math.ts index 2a4468bf2e..bc118137a1 100644 --- a/web_src/js/markup/math.ts +++ b/web_src/js/markup/math.ts @@ -1,4 +1,5 @@ import {displayError} from './common.ts'; +import {queryElems} from '../utils/dom.ts'; function targetElement(el: Element): {target: Element, displayAsBlock: boolean} { // The target element is either the parent "code block with loading indicator", or itself @@ -12,35 +13,35 @@ function targetElement(el: Element): {target: Element, displayAsBlock: boolean} } export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> { - const el = elMarkup.querySelector('code.language-math'); // .markup code.language-math' - if (!el) return; + // .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'), + ]); - const [{default: katex}] = await Promise.all([ - import(/* webpackChunkName: "katex" */'katex'), - import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), - ]); + const MAX_CHARS = 1000; + const MAX_SIZE = 25; + const MAX_EXPAND = 1000; - const MAX_CHARS = 1000; - const MAX_SIZE = 25; - const MAX_EXPAND = 1000; + const {target, displayAsBlock} = targetElement(el); + if (target.hasAttribute('data-render-done')) return; + const source = el.textContent; - const {target, displayAsBlock} = targetElement(el); - if (target.hasAttribute('data-render-done')) return; - const source = el.textContent; - - if (source.length > MAX_CHARS) { - displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`)); - return; - } - try { - const tempEl = document.createElement(displayAsBlock ? 'p' : 'span'); - katex.render(source, tempEl, { - maxSize: MAX_SIZE, - maxExpand: MAX_EXPAND, - displayMode: displayAsBlock, // katex: true for display (block) mode, false for inline mode - }); - target.replaceWith(tempEl); - } catch (error) { - displayError(target, error); - } + if (source.length > MAX_CHARS) { + displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`)); + return; + } + try { + const tempEl = document.createElement(displayAsBlock ? 'p' : 'span'); + katex.render(source, tempEl, { + maxSize: MAX_SIZE, + maxExpand: MAX_EXPAND, + displayMode: displayAsBlock, // katex: true for display (block) mode, false for inline mode + }); + target.replaceWith(tempEl); + } catch (error) { + displayError(target, error); + } + }); } diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index b4bf3153ea..33d9a1ed9b 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -1,6 +1,8 @@ import {isDarkTheme} from '../utils.ts'; import {makeCodeCopyButton} from './codecopy.ts'; import {displayError} from './common.ts'; +import {queryElems} from '../utils/dom.ts'; +import {html, htmlRaw} from '../utils/html.ts'; const {mermaidMaxSourceCharacters} = window.config; @@ -11,77 +13,77 @@ body {margin: 0; padding: 0; overflow: hidden} blockquote, dd, dl, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {margin: 0}`; export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> { - const el = elMarkup.querySelector('code.language-mermaid'); // .markup code.language-mermaid - if (!el) return; - - const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); - - mermaid.initialize({ - startOnLoad: false, - theme: isDarkTheme() ? 'dark' : 'neutral', - securityLevel: 'strict', - suppressErrorRendering: true, - }); - - const pre = el.closest('pre'); - if (pre.hasAttribute('data-render-done')) return; - - const source = el.textContent; - if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) { - displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); - return; - } - - try { - await mermaid.parse(source); - } catch (err) { - displayError(pre, err); - return; - } - - try { - // can't use bindFunctions here because we can't cross the iframe boundary. This - // means js-based interactions won't work but they aren't intended to work either - const {svg} = await mermaid.render('mermaid', source); - - const iframe = document.createElement('iframe'); - iframe.classList.add('markup-content-iframe', 'tw-invisible'); - iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`; - - const mermaidBlock = document.createElement('div'); - mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); - mermaidBlock.append(iframe); - - const btn = makeCodeCopyButton(); - btn.setAttribute('data-clipboard-text', source); - mermaidBlock.append(btn); - - const updateIframeHeight = () => { - const body = iframe.contentWindow?.document?.body; - if (body) { - iframe.style.height = `${body.clientHeight}px`; - } - }; - - iframe.addEventListener('load', () => { - pre.replaceWith(mermaidBlock); - mermaidBlock.classList.remove('tw-hidden'); - updateIframeHeight(); - setTimeout(() => { // avoid flash of iframe background - mermaidBlock.classList.remove('is-loading'); - iframe.classList.remove('tw-invisible'); - }, 0); - - // update height when element's visibility state changes, for example when the diagram is inside - // a <details> + <summary> block and the <details> block becomes visible upon user interaction, it - // would initially set a incorrect height and the correct height is set during this callback. - (new IntersectionObserver(() => { - updateIframeHeight(); - }, {root: document.documentElement})).observe(iframe); + // .markup code.language-mermaid + queryElems(elMarkup, 'code.language-mermaid', async (el) => { + const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); + + mermaid.initialize({ + startOnLoad: false, + theme: isDarkTheme() ? 'dark' : 'neutral', + securityLevel: 'strict', + suppressErrorRendering: true, }); - document.body.append(mermaidBlock); - } catch (err) { - displayError(pre, err); - } + const pre = el.closest('pre'); + if (pre.hasAttribute('data-render-done')) return; + + const source = el.textContent; + if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) { + displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); + return; + } + + try { + await mermaid.parse(source); + } catch (err) { + displayError(pre, err); + return; + } + + try { + // can't use bindFunctions here because we can't cross the iframe boundary. This + // means js-based interactions won't work but they aren't intended to work either + const {svg} = await mermaid.render('mermaid', source); + + const iframe = document.createElement('iframe'); + iframe.classList.add('markup-content-iframe', 'tw-invisible'); + iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`; + + const mermaidBlock = document.createElement('div'); + mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); + mermaidBlock.append(iframe); + + const btn = makeCodeCopyButton(); + btn.setAttribute('data-clipboard-text', source); + mermaidBlock.append(btn); + + const updateIframeHeight = () => { + const body = iframe.contentWindow?.document?.body; + if (body) { + iframe.style.height = `${body.clientHeight}px`; + } + }; + + iframe.addEventListener('load', () => { + pre.replaceWith(mermaidBlock); + mermaidBlock.classList.remove('tw-hidden'); + updateIframeHeight(); + setTimeout(() => { // avoid flash of iframe background + mermaidBlock.classList.remove('is-loading'); + iframe.classList.remove('tw-invisible'); + }, 0); + + // update height when element's visibility state changes, for example when the diagram is inside + // a <details> + <summary> block and the <details> block becomes visible upon user interaction, it + // would initially set a incorrect height and the correct height is set during this callback. + (new IntersectionObserver(() => { + updateIframeHeight(); + }, {root: document.documentElement})).observe(iframe); + }); + + document.body.append(mermaidBlock); + } catch (err) { + displayError(pre, err); + } + }); } diff --git a/web_src/js/modules/diff-file.test.ts b/web_src/js/modules/diff-file.test.ts new file mode 100644 index 0000000000..f0438538a0 --- /dev/null +++ b/web_src/js/modules/diff-file.test.ts @@ -0,0 +1,51 @@ +import {diffTreeStoreSetViewed, reactiveDiffTreeStore} from './diff-file.ts'; + +test('diff-tree', () => { + const store = reactiveDiffTreeStore({ + 'TreeRoot': { + 'FullName': '', + 'DisplayName': '', + 'EntryMode': '', + 'IsViewed': false, + 'NameHash': '....', + 'DiffStatus': '', + 'FileIcon': '', + 'Children': [ + { + 'FullName': 'dir1', + 'DisplayName': 'dir1', + 'EntryMode': 'tree', + 'IsViewed': false, + 'NameHash': '....', + 'DiffStatus': '', + 'FileIcon': '', + 'Children': [ + { + 'FullName': 'dir1/test.txt', + 'DisplayName': 'test.txt', + 'DiffStatus': 'added', + 'NameHash': '....', + 'EntryMode': '', + 'IsViewed': false, + 'FileIcon': '', + 'Children': null, + }, + ], + }, + { + 'FullName': 'other.txt', + 'DisplayName': 'other.txt', + 'NameHash': '........', + 'DiffStatus': 'added', + 'EntryMode': '', + 'IsViewed': false, + 'FileIcon': '', + 'Children': null, + }, + ], + }, + }, '', ''); + diffTreeStoreSetViewed(store, 'dir1/test.txt', true); + expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true); + expect(store.fullNameMap['dir1'].IsViewed).toBe(true); +}); diff --git a/web_src/js/modules/diff-file.ts b/web_src/js/modules/diff-file.ts new file mode 100644 index 0000000000..2cec7bc6b3 --- /dev/null +++ b/web_src/js/modules/diff-file.ts @@ -0,0 +1,82 @@ +import {reactive} from 'vue'; +import type {Reactive} from 'vue'; + +const {pageData} = window.config; + +export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange'; + +export type DiffTreeEntry = { + FullName: string, + DisplayName: string, + NameHash: string, + DiffStatus: DiffStatus, + EntryMode: string, + IsViewed: boolean, + Children: DiffTreeEntry[], + FileIcon: string, + ParentEntry?: DiffTreeEntry, +} + +type DiffFileTreeData = { + TreeRoot: DiffTreeEntry, +}; + +type DiffFileTree = { + folderIcon: string; + folderOpenIcon: string; + diffFileTree: DiffFileTreeData; + fullNameMap?: Record<string, DiffTreeEntry> + fileTreeIsVisible: boolean; + selectedItem: string; +} + +let diffTreeStoreReactive: Reactive<DiffFileTree>; +export function diffTreeStore() { + if (!diffTreeStoreReactive) { + diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree, pageData.FolderIcon, pageData.FolderOpenIcon); + } + return diffTreeStoreReactive; +} + +export function diffTreeStoreSetViewed(store: Reactive<DiffFileTree>, fullName: string, viewed: boolean) { + const entry = store.fullNameMap[fullName]; + if (!entry) return; + entry.IsViewed = viewed; + for (let parent = entry.ParentEntry; parent; parent = parent.ParentEntry) { + parent.IsViewed = isEntryViewed(parent); + } +} + +function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntry) { + map[entry.FullName] = entry; + if (!entry.Children) return; + entry.IsViewed = isEntryViewed(entry); + for (const child of entry.Children) { + child.ParentEntry = entry; + fillFullNameMap(map, child); + } +} + +export function reactiveDiffTreeStore(data: DiffFileTreeData, folderIcon: string, folderOpenIcon: string): Reactive<DiffFileTree> { + const store = reactive({ + diffFileTree: data, + folderIcon, + folderOpenIcon, + fileTreeIsVisible: false, + selectedItem: '', + fullNameMap: {}, + }); + fillFullNameMap(store.fullNameMap, data.TreeRoot); + return store; +} + +function isEntryViewed(entry: DiffTreeEntry): boolean { + if (entry.Children) { + let count = 0; + for (const child of entry.Children) { + if (child.IsViewed) count++; + } + return count === entry.Children.length; + } + return entry.IsViewed; +} diff --git a/web_src/js/modules/fomantic/dropdown.test.ts b/web_src/js/modules/fomantic/dropdown.test.ts index 587e0bca7c..dd3497c8fc 100644 --- a/web_src/js/modules/fomantic/dropdown.test.ts +++ b/web_src/js/modules/fomantic/dropdown.test.ts @@ -23,7 +23,27 @@ test('hideScopedEmptyDividers-simple', () => { `); }); -test('hideScopedEmptyDividers-hidden1', () => { +test('hideScopedEmptyDividers-items-all-filtered', () => { + const container = createElementFromHTML(`<div> +<div class="any"></div> +<div class="divider"></div> +<div class="item filtered">a</div> +<div class="item filtered">b</div> +<div class="divider"></div> +<div class="any"></div> +</div>`); + hideScopedEmptyDividers(container); + expect(container.innerHTML).toEqual(` +<div class="any"></div> +<div class="divider hidden transition"></div> +<div class="item filtered">a</div> +<div class="item filtered">b</div> +<div class="divider"></div> +<div class="any"></div> +`); +}); + +test('hideScopedEmptyDividers-hide-last', () => { const container = createElementFromHTML(`<div> <div class="item">a</div> <div class="divider" data-scope="b"></div> @@ -37,7 +57,7 @@ test('hideScopedEmptyDividers-hidden1', () => { `); }); -test('hideScopedEmptyDividers-hidden2', () => { +test('hideScopedEmptyDividers-scoped-items', () => { const container = createElementFromHTML(`<div> <div class="item" data-scope="">a</div> <div class="divider" data-scope="b"></div> diff --git a/web_src/js/modules/fomantic/dropdown.ts b/web_src/js/modules/fomantic/dropdown.ts index 8736e041df..ccc22073d7 100644 --- a/web_src/js/modules/fomantic/dropdown.ts +++ b/web_src/js/modules/fomantic/dropdown.ts @@ -11,24 +11,34 @@ export function initAriaDropdownPatch() { if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once'); $.fn.dropdown = ariaDropdownFn; $.fn.fomanticExt.onResponseKeepSelectedItem = onResponseKeepSelectedItem; + $.fn.fomanticExt.onDropdownAfterFiltered = onDropdownAfterFiltered; (ariaDropdownFn as FomanticInitFunction).settings = fomanticDropdownFn.settings; } // the patched `$.fn.dropdown` function, it passes the arguments to Fomantic's `$.fn.dropdown` function, and: -// * it does the one-time attaching on the first call -// * it delegates the `onLabelCreate` to the patched `onLabelCreate` to add necessary aria attributes +// * it does the one-time element event attaching on the first call +// * it delegates the module internal functions like `onLabelCreate` to the patched functions to add more features. function ariaDropdownFn(this: any, ...args: Parameters<FomanticInitFunction>) { const ret = fomanticDropdownFn.apply(this, args); - // if the `$().dropdown()` call is without arguments, or it has non-string (object) argument, - // it means that this call will reset the dropdown internal settings, then we need to re-delegate the callbacks. - const needDelegate = (!args.length || typeof args[0] !== 'string'); - for (const el of this) { + for (let el of this) { + // dropdown will replace '<select class="ui dropdown"/>' to '<div class="ui dropdown"><select (hidden)></select><div class="menu">...</div></div>' + // so we need to correctly find the closest '.ui.dropdown' element, it is the real fomantic dropdown module. + el = el.closest('.ui.dropdown'); if (!el[ariaPatchKey]) { - attachInit(el); + // the elements don't belong to the dropdown "module" and won't be reset + // so we only need to initialize them once. + attachInitElements(el); } - if (needDelegate) { - delegateOne($(el)); + + // if the `$().dropdown()` is called without arguments, or it has non-string (object) argument, + // it means that such call will reset the dropdown "module" including internal settings, + // then we need to re-delegate the callbacks. + const $dropdown = $(el); + const dropdownModule = $dropdown.data('module-dropdown'); + if (!dropdownModule.giteaDelegated) { + dropdownModule.giteaDelegated = true; + delegateDropdownModule($dropdown); } } return ret; @@ -61,37 +71,17 @@ function updateSelectionLabel(label: HTMLElement) { } } -function processMenuItems($dropdown: any, dropdownCall: any) { - const hideEmptyDividers = dropdownCall('setting', 'hideDividers') === 'empty'; +function onDropdownAfterFiltered(this: any) { + const $dropdown = $(this).closest('.ui.dropdown'); // "this" can be the "ui dropdown" or "<select>" + const hideEmptyDividers = $dropdown.dropdown('setting', 'hideDividers') === 'empty'; const itemsMenu = $dropdown[0].querySelector('.scrolling.menu') || $dropdown[0].querySelector('.menu'); - if (hideEmptyDividers) hideScopedEmptyDividers(itemsMenu); + if (hideEmptyDividers && itemsMenu) hideScopedEmptyDividers(itemsMenu); } // delegate the dropdown's template functions and callback functions to add aria attributes. -function delegateOne($dropdown: any) { +function delegateDropdownModule($dropdown: any) { const dropdownCall = fomanticDropdownFn.bind($dropdown); - // If there is a "search input" in the "menu", Fomantic will only "focus the input" but not "toggle the menu" when the "dropdown icon" is clicked. - // Actually, Fomantic UI doesn't support such layout/usage. It needs to patch the "focusSearch" / "blurSearch" functions to make sure it toggles the menu. - const oldFocusSearch = dropdownCall('internal', 'focusSearch'); - const oldBlurSearch = dropdownCall('internal', 'blurSearch'); - // * If the "dropdown icon" is clicked, Fomantic calls "focusSearch", so show the menu - dropdownCall('internal', 'focusSearch', function (this: any) { dropdownCall('show'); oldFocusSearch.call(this) }); - // * If the "dropdown icon" is clicked again when the menu is visible, Fomantic calls "blurSearch", so hide the menu - dropdownCall('internal', 'blurSearch', function (this: any) { oldBlurSearch.call(this); dropdownCall('hide') }); - - const oldFilterItems = dropdownCall('internal', 'filterItems'); - dropdownCall('internal', 'filterItems', function (this: any, ...args: any[]) { - oldFilterItems.call(this, ...args); - processMenuItems($dropdown, dropdownCall); - }); - - const oldShow = dropdownCall('internal', 'show'); - dropdownCall('internal', 'show', function (this: any, ...args: any[]) { - oldShow.call(this, ...args); - processMenuItems($dropdown, dropdownCall); - }); - // the "template" functions are used for dynamic creation (eg: AJAX) const dropdownTemplates = {...dropdownCall('setting', 'templates'), t: performance.now()}; const dropdownTemplatesMenuOld = dropdownTemplates.menu; @@ -163,9 +153,8 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men } } -function attachInit(dropdown: HTMLElement) { +function attachInitElements(dropdown: HTMLElement) { (dropdown as any)[ariaPatchKey] = {}; - if (dropdown.classList.contains('custom')) return; // Dropdown has 2 different focusing behaviors // * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element. @@ -239,12 +228,13 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT dropdown.addEventListener('keydown', (e: KeyboardEvent) => { // here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler if (e.key === 'Enter') { - const dropdownCall = fomanticDropdownFn.bind($(dropdown)); - let $item = dropdownCall('get item', dropdownCall('get value')); - if (!$item) $item = $(menu).find('> .item.selected'); // when dropdown filters items by input, there is no "value", so query the "selected" item + const elItem = menu.querySelector<HTMLElement>(':scope > .item.selected, .menu > .item.selected'); // if the selected item is clickable, then trigger the click event. // we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click. - if ($item?.[0]?.matches('a, .js-aria-clickable')) $item[0].click(); + if (elItem?.matches('a, .js-aria-clickable') && !elItem.matches('.tw-hidden, .filtered')) { + e.preventDefault(); + elItem.click(); + } } }); @@ -305,9 +295,11 @@ export function hideScopedEmptyDividers(container: Element) { const visibleItems: Element[] = []; const curScopeVisibleItems: Element[] = []; let curScope: string = '', lastVisibleScope: string = ''; - const isScopedDivider = (item: Element) => item.matches('.divider') && item.hasAttribute('data-scope'); + const isDivider = (item: Element) => item.classList.contains('divider'); + const isScopedDivider = (item: Element) => isDivider(item) && item.hasAttribute('data-scope'); const hideDivider = (item: Element) => item.classList.add('hidden', 'transition'); // dropdown has its own classes to hide items - + const showDivider = (item: Element) => item.classList.remove('hidden', 'transition'); + const isHidden = (item: Element) => item.classList.contains('hidden') || item.classList.contains('filtered') || item.classList.contains('tw-hidden'); const handleScopeSwitch = (itemScope: string) => { if (curScopeVisibleItems.length === 1 && isScopedDivider(curScopeVisibleItems[0])) { hideDivider(curScopeVisibleItems[0]); @@ -323,13 +315,16 @@ export function hideScopedEmptyDividers(container: Element) { curScopeVisibleItems.length = 0; }; + // reset hidden dividers + queryElems(container, '.divider', showDivider); + // hide the scope dividers if the scope items are empty for (const item of container.children) { const itemScope = item.getAttribute('data-scope') || ''; if (itemScope !== curScope) { handleScopeSwitch(itemScope); } - if (!item.classList.contains('filtered') && !item.classList.contains('tw-hidden')) { + if (!isHidden(item)) { curScopeVisibleItems.push(item as HTMLElement); } } @@ -337,20 +332,20 @@ export function hideScopedEmptyDividers(container: Element) { // hide all leading and trailing dividers while (visibleItems.length) { - if (!visibleItems[0].matches('.divider')) break; + if (!isDivider(visibleItems[0])) break; hideDivider(visibleItems[0]); visibleItems.shift(); } while (visibleItems.length) { - if (!visibleItems[visibleItems.length - 1].matches('.divider')) break; + if (!isDivider(visibleItems[visibleItems.length - 1])) break; hideDivider(visibleItems[visibleItems.length - 1]); visibleItems.pop(); } // hide all duplicate dividers, hide current divider if next sibling is still divider // no need to update "visibleItems" array since this is the last loop - for (const item of visibleItems) { - if (!item.matches('.divider')) continue; - if (item.nextElementSibling?.matches('.divider')) hideDivider(item); + for (let i = 0; i < visibleItems.length - 1; i++) { + if (!visibleItems[i].matches('.divider')) continue; + if (visibleItems[i + 1].matches('.divider')) hideDivider(visibleItems[i]); } } diff --git a/web_src/js/modules/fomantic/modal.ts b/web_src/js/modules/fomantic/modal.ts index 6a2c558890..a96c7785e1 100644 --- a/web_src/js/modules/fomantic/modal.ts +++ b/web_src/js/modules/fomantic/modal.ts @@ -1,5 +1,7 @@ import $ from 'jquery'; import type {FomanticInitFunction} from '../../types.ts'; +import {queryElems} from '../../utils/dom.ts'; +import {hideToastsFrom} from '../toast.ts'; const fomanticModalFn = $.fn.modal; @@ -8,6 +10,8 @@ export function initAriaModalPatch() { if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once'); $.fn.modal = ariaModalFn; (ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings; + $.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden; + $.fn.modal.settings.onApprove = onModalApproveDefault; } // the patched `$.fn.modal` modal function @@ -27,3 +31,33 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) { } return ret; } + +function onModalBeforeHidden(this: any) { + const $modal = $(this); + const elModal = $modal[0]; + hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body); + + // reset the form after the modal is hidden, after other modal events and handlers (e.g. "onApprove", form submit) + setTimeout(() => { + queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset()); + }, 0); +} + +function onModalApproveDefault(this: any) { + const $modal = $(this); + const selectors = $modal.modal('setting', 'selector'); + const elModal = $modal[0]; + const elApprove = elModal.querySelector(selectors.approve); + const elForm = elApprove?.closest('form'); + if (!elForm) return true; // no form, just allow closing the modal + + // "form-fetch-action" can handle network errors gracefully, + // so keep the modal dialog to make users can re-submit the form if anything wrong happens. + if (elForm.matches('.form-fetch-action')) return false; + + // There is an abuse for the "modal" + "form" combination, the "Approve" button is a traditional form submit button in the form. + // Then "approve" and "submit" occur at the same time, the modal will be closed immediately before the form is submitted. + // So here we prevent the modal from closing automatically by returning false, add the "is-loading" class to the form element. + elForm.classList.add('is-loading'); + return false; +} diff --git a/web_src/js/modules/observer.ts b/web_src/js/modules/observer.ts index 06208d0507..3305c2f29d 100644 --- a/web_src/js/modules/observer.ts +++ b/web_src/js/modules/observer.ts @@ -46,9 +46,11 @@ function callGlobalInitFunc(el: HTMLElement) { const func = globalInitFuncs[initFunc]; if (!func) throw new Error(`Global init function "${initFunc}" not found`); + // when an element node is removed and added again, it should not be re-initialized again. type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean}; - if ((el as GiteaGlobalInitElement)._giteaGlobalInited) throw new Error(`Global init function "${initFunc}" already executed`); + if ((el as GiteaGlobalInitElement)._giteaGlobalInited) return; (el as GiteaGlobalInitElement)._giteaGlobalInited = true; + func(el); } diff --git a/web_src/js/modules/stores.ts b/web_src/js/modules/stores.ts deleted file mode 100644 index 65da1e044a..0000000000 --- a/web_src/js/modules/stores.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {reactive} from 'vue'; -import type {Reactive} from 'vue'; - -const {pageData} = window.config; - -let diffTreeStoreReactive: Reactive<Record<string, any>>; -export function diffTreeStore() { - if (!diffTreeStoreReactive) { - diffTreeStoreReactive = reactive({ - files: pageData.DiffFiles, - fileTreeIsVisible: false, - selectedItem: '', - }); - } - return diffTreeStoreReactive; -} diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index af715f48b9..2a1d998d76 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js'; import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import {formatDatetime} from '../utils/time.ts'; import type {Content, Instance, Placement, Props} from 'tippy.js'; +import {html} from '../utils/html.ts'; type TippyOpts = { role?: string, @@ -9,7 +10,7 @@ type TippyOpts = { } & Partial<Props>; const visibleInstances = new Set<Instance>(); -const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`; +const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`; export function createTippy(target: Element, opts: TippyOpts = {}): Instance { // the callback functions should be destructured from opts, @@ -40,6 +41,7 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance { } } visibleInstances.add(instance); + target.setAttribute('aria-controls', instance.popper.id); return onShow?.(instance); }, arrow: arrow ?? (theme === 'bare' ? false : arrowSvg), @@ -180,13 +182,25 @@ export function initGlobalTooltips(): void { } export function showTemporaryTooltip(target: Element, content: Content): void { - // if the target is inside a dropdown, the menu will be hidden soon - // so display the tooltip on the dropdown instead - target = target.closest('.ui.dropdown') || target; - const tippy = target._tippy ?? attachTooltip(target, content); - tippy.setContent(content); - if (!tippy.state.isShown) tippy.show(); - tippy.setProps({ + // if the target is inside a dropdown or tippy popup, the menu will be hidden soon + // so display the tooltip on the "aria-controls" element or dropdown instead + let refClientRect: DOMRect; + const popupTippyId = target.closest(`[data-tippy-root]`)?.id; + if (popupTippyId) { + // for example, the "Copy Permalink" button in the "File View" page for the selected lines + target = document.body; + refClientRect = document.querySelector(`[aria-controls="${CSS.escape(popupTippyId)}"]`)?.getBoundingClientRect(); + refClientRect = refClientRect ?? new DOMRect(0, 0, 0, 0); // fallback to empty rect if not found, tippy doesn't accept null + } else { + // for example, the "Copy Link" button in the issue header dropdown menu + target = target.closest('.ui.dropdown') ?? target; + refClientRect = target.getBoundingClientRect(); + } + const tooltipTippy = target._tippy ?? attachTooltip(target, content); + tooltipTippy.setContent(content); + tooltipTippy.setProps({getReferenceClientRect: () => refClientRect}); + if (!tooltipTippy.state.isShown) tooltipTippy.show(); + tooltipTippy.setProps({ onHidden: (tippy) => { // reset the default tooltip content, if no default, then this temporary tooltip could be destroyed if (!attachTooltip(target)) { diff --git a/web_src/js/modules/toast.ts b/web_src/js/modules/toast.ts index 36e2321743..ed807a4977 100644 --- a/web_src/js/modules/toast.ts +++ b/web_src/js/modules/toast.ts @@ -1,6 +1,6 @@ -import {htmlEscape} from 'escape-goat'; +import {htmlEscape} from '../utils/html.ts'; import {svg} from '../svg.ts'; -import {animateOnce, showElem} from '../utils/dom.ts'; +import {animateOnce, queryElems, showElem} from '../utils/dom.ts'; import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown import type {Intent} from '../types.ts'; import type {SvgName} from '../svg.ts'; @@ -37,17 +37,20 @@ const levels: ToastLevels = { type ToastOpts = { useHtmlBody?: boolean, - preventDuplicates?: boolean, + preventDuplicates?: boolean | string, } & Options; +type ToastifyElement = HTMLElement & {_giteaToastifyInstance?: Toast }; + // See https://github.com/apvarun/toastify-js#api for options function showToast(message: string, level: Intent, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other}: ToastOpts = {}): Toast { const body = useHtmlBody ? String(message) : htmlEscape(message); - const key = `${level}-${body}`; + const parent = document.querySelector('.ui.dimmer.active') ?? document.body; + const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${body}` : preventDuplicates) : ''; - // prevent showing duplicate toasts with same level and message, and give a visual feedback for end users + // prevent showing duplicate toasts with the same level and message, and give visual feedback for end users if (preventDuplicates) { - const toastEl = document.querySelector(`.toastify[data-toast-unique-key="${CSS.escape(key)}"]`); + const toastEl = parent.querySelector(`:scope > .toastify.on[data-toast-unique-key="${CSS.escape(duplicateKey)}"]`); if (toastEl) { const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number'); showElem(toastDupNumEl); @@ -59,6 +62,7 @@ function showToast(message: string, level: Intent, {gravity, position, duration, const {icon, background, duration: levelDuration} = levels[level ?? 'info']; const toast = Toastify({ + selector: parent, text: ` <div class='toast-icon'>${svg(icon)}</div> <div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${body}</div> @@ -74,7 +78,8 @@ function showToast(message: string, level: Intent, {gravity, position, duration, toast.showToast(); toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast()); - toast.toastElement.setAttribute('data-toast-unique-key', key); + toast.toastElement.setAttribute('data-toast-unique-key', duplicateKey); + (toast.toastElement as ToastifyElement)._giteaToastifyInstance = toast; return toast; } @@ -89,3 +94,15 @@ export function showWarningToast(message: string, opts?: ToastOpts): Toast { export function showErrorToast(message: string, opts?: ToastOpts): Toast { return showToast(message, 'error', opts); } + +function hideToastByElement(el: Element): void { + (el as ToastifyElement)?._giteaToastifyInstance?.hideToast(); +} + +export function hideToastsFrom(parent: Element): void { + queryElems(parent, ':scope > .toastify.on', hideToastByElement); +} + +export function hideToastsAll(): void { + queryElems(document, '.toastify.on', hideToastByElement); +} diff --git a/web_src/js/render/pdf.ts b/web_src/js/render/pdf.ts deleted file mode 100644 index 283b4ed85c..0000000000 --- a/web_src/js/render/pdf.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {htmlEscape} from 'escape-goat'; -import {registerGlobalInitFunc} from '../modules/observer.ts'; - -export async function initPdfViewer() { - registerGlobalInitFunc('initPdfViewer', async (el: HTMLInputElement) => { - const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); - - const src = el.getAttribute('data-src'); - const fallbackText = el.getAttribute('data-fallback-button-text'); - pdfobject.embed(src, el, { - fallbackLink: htmlEscape` - <a role="button" class="ui basic button pdf-fallback-button" href="[url]">${fallbackText}</a> - `, - }); - el.classList.remove('is-loading'); - }); -} diff --git a/web_src/js/render/plugin.ts b/web_src/js/render/plugin.ts new file mode 100644 index 0000000000..a8dd0a7c05 --- /dev/null +++ b/web_src/js/render/plugin.ts @@ -0,0 +1,10 @@ +export type FileRenderPlugin = { + // unique plugin name + name: string; + + // test if plugin can handle a specified file + canHandle: (filename: string, mimeType: string) => boolean; + + // render file content + render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>; +} diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts new file mode 100644 index 0000000000..2a0929359d --- /dev/null +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -0,0 +1,60 @@ +import type {FileRenderPlugin} from '../plugin.ts'; +import {extname} from '../../utils.ts'; + +// support common 3D model file formats, use online-3d-viewer library for rendering + +// eslint-disable-next-line multiline-comment-style +/* a simple text STL file example: +solid SimpleTriangle + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet +endsolid SimpleTriangle +*/ + +export function newRenderPlugin3DViewer(): FileRenderPlugin { + // Some extensions are text-based formats: + // .3mf .amf .brep: XML + // .fbx: XML or BINARY + // .dae .gltf: JSON + // .ifc, .igs, .iges, .stp, .step are: TEXT + // .stl .ply: TEXT or BINARY + // .obj .off .wrl: TEXT + // So we need to be able to render when the file is recognized as plaintext file by backend. + // + // It needs more logic to make it overall right (render a text 3D model automatically): + // we need to distinguish the ambiguous filename extensions. + // For example: "*.obj, *.off, *.step" might be or not be a 3D model file. + // So when it is a text file, we can't assume that "we only render it by 3D plugin", + // otherwise the end users would be impossible to view its real content when the file is not a 3D model. + const SUPPORTED_EXTENSIONS = [ + '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', + '.dae', '.fbx', '.fcstd', '.glb', '.gltf', + '.ifc', '.igs', '.iges', '.stp', '.step', + '.stl', '.obj', '.off', '.ply', '.wrl', + ]; + + return { + name: '3d-model-viewer', + + canHandle(filename: string, _mimeType: string): boolean { + const ext = extname(filename).toLowerCase(); + return SUPPORTED_EXTENSIONS.includes(ext); + }, + + 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 viewer = new OV.EmbeddedViewer(container, { + backgroundColor: new OV.RGBAColor(59, 68, 76, 0), + defaultColor: new OV.RGBColor(65, 131, 196), + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), + }); + viewer.LoadModelFromUrlList([fileUrl]); + }, + }; +} diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts new file mode 100644 index 0000000000..40623be055 --- /dev/null +++ b/web_src/js/render/plugins/pdf-viewer.ts @@ -0,0 +1,20 @@ +import type {FileRenderPlugin} from '../plugin.ts'; + +export function newRenderPluginPdfViewer(): FileRenderPlugin { + return { + name: 'pdf-viewer', + + canHandle(filename: string, _mimeType: string): boolean { + return filename.toLowerCase().endsWith('.pdf'); + }, + + async render(container: HTMLElement, fileUrl: string): Promise<void> { + const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); + // TODO: the PDFObject library does not support dynamic height adjustment, + container.style.height = `${window.innerHeight - 100}px`; + if (!PDFObject.default.embed(fileUrl, container)) { + throw new Error('Unable to render the PDF file'); + } + }, + }; +} diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts index 7b377e1ab4..50c9536f37 100644 --- a/web_src/js/svg.ts +++ b/web_src/js/svg.ts @@ -1,5 +1,6 @@ import {defineComponent, h, type PropType} from 'vue'; import {parseDom, serializeXml} from './utils.ts'; +import {html, htmlRaw} from './utils/html.ts'; import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg'; import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg'; import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg'; @@ -220,7 +221,7 @@ export const SvgIcon = defineComponent({ const classes = Array.from(svgOuter.classList); if (this.symbolId) { classes.push('tw-hidden', 'svg-symbol-container'); - svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`; + svgInnerHtml = html`<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${htmlRaw(svgInnerHtml)}</symbol>`; } // create VNode return h('svg', { diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts index b825a9339d..e33b1413e8 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -1,5 +1,6 @@ import {decode, encode} from 'uint8-to-base64'; import type {IssuePageInfo, IssuePathInfo, RepoOwnerPathInfo} from './types.ts'; +import {toggleClass, toggleElem} from './utils/dom.ts'; // transform /path/to/file.ext to /path/to export function dirname(path: string): string { @@ -179,3 +180,24 @@ export function isImageFile({name, type}: {name?: string, type?: string}): boole export function isVideoFile({name, type}: {name?: string, type?: string}): boolean { return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/'); } + +export function toggleFullScreen(fullscreenElementsSelector: string, isFullScreen: boolean, sourceParentSelector?: string): void { + // hide other elements + const headerEl = document.querySelector('#navbar'); + const contentEl = document.querySelector('.page-content'); + const footerEl = document.querySelector('.page-footer'); + toggleElem(headerEl, !isFullScreen); + toggleElem(contentEl, !isFullScreen); + toggleElem(footerEl, !isFullScreen); + + const sourceParentEl = sourceParentSelector ? document.querySelector(sourceParentSelector) : contentEl; + + const fullScreenEl = document.querySelector(fullscreenElementsSelector); + const outerEl = document.querySelector('.full.height'); + toggleClass(fullscreenElementsSelector, 'fullscreen', isFullScreen); + if (isFullScreen) { + outerEl.append(fullScreenEl); + } else { + sourceParentEl.append(fullScreenEl); + } +} diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index 6e71596850..057ea9808c 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -1,4 +1,10 @@ -import {createElementFromAttrs, createElementFromHTML, queryElemChildren, querySingleVisibleElem} from './dom.ts'; +import { + createElementFromAttrs, + createElementFromHTML, + queryElemChildren, + querySingleVisibleElem, + toggleElem, +} from './dom.ts'; test('createElementFromHTML', () => { expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>'); @@ -19,10 +25,14 @@ test('createElementFromAttrs', () => { }); test('querySingleVisibleElem', () => { - let el = createElementFromHTML('<div><span>foo</span></div>'); + let el = createElementFromHTML('<div></div>'); + expect(querySingleVisibleElem(el, 'span')).toBeNull(); + el = createElementFromHTML('<div><span>foo</span></div>'); expect(querySingleVisibleElem(el, 'span').textContent).toEqual('foo'); el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>'); expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar'); + el = createElementFromHTML('<div><span class="some-class tw-hidden">foo</span><span>bar</span></div>'); + expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar'); el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>'); expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element'); }); @@ -32,3 +42,13 @@ test('queryElemChildren', () => { const children = queryElemChildren(el, '.a'); expect(children.length).toEqual(1); }); + +test('toggleElem', () => { + const el = createElementFromHTML('<p><div>a</div><div class="tw-hidden">b</div></p>'); + toggleElem(el.children); + expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="">b</div></p>'); + toggleElem(el.children, false); + expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="tw-hidden">b</div></p>'); + toggleElem(el.children, true); + expect(el.outerHTML).toEqual('<p><div class="">a</div><div class="">b</div></p>'); +}); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 4d15784e6e..8b540cebb1 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -9,55 +9,50 @@ type ElementsCallback<T extends Element> = (el: T) => Promisable<any>; type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>; export type DOMEvent<E extends Event, T extends Element = HTMLElement> = E & { target: Partial<T>; }; -function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) { +function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]): ArrayLikeIterable<Element> { if (typeof el === 'string' || el instanceof String) { el = document.querySelectorAll(el as string); } if (el instanceof Node) { func(el, ...args); + return [el]; } else if (el.length !== undefined) { // this works for: NodeList, HTMLCollection, Array, jQuery - for (const e of (el as ArrayLikeIterable<Element>)) { - func(e, ...args); - } - } else { - throw new Error('invalid argument to be shown/hidden'); + const elems = el as ArrayLikeIterable<Element>; + for (const elem of elems) func(elem, ...args); + return elems; } + throw new Error('invalid argument to be shown/hidden'); +} + +export function toggleClass(el: ElementArg, className: string, force?: boolean): ArrayLikeIterable<Element> { + return elementsCall(el, (e: Element) => { + if (force === true) { + e.classList.add(className); + } else if (force === false) { + e.classList.remove(className); + } else if (force === undefined) { + e.classList.toggle(className); + } else { + throw new Error('invalid force argument'); + } + }); } /** - * @param el Element + * @param el ElementArg * @param force force=true to show or force=false to hide, undefined to toggle */ -function toggleShown(el: Element, force: boolean) { - if (force === true) { - el.classList.remove('tw-hidden'); - } else if (force === false) { - el.classList.add('tw-hidden'); - } else if (force === undefined) { - el.classList.toggle('tw-hidden'); - } else { - throw new Error('invalid force argument'); - } +export function toggleElem(el: ElementArg, force?: boolean): ArrayLikeIterable<Element> { + return toggleClass(el, 'tw-hidden', force === undefined ? force : !force); } -export function showElem(el: ElementArg) { - elementsCall(el, toggleShown, true); +export function showElem(el: ElementArg): ArrayLikeIterable<Element> { + return toggleElem(el, true); } -export function hideElem(el: ElementArg) { - elementsCall(el, toggleShown, false); -} - -export function toggleElem(el: ElementArg, force?: boolean) { - elementsCall(el, toggleShown, force); -} - -export function isElemHidden(el: ElementArg) { - const res: boolean[] = []; - elementsCall(el, (e) => res.push(e.classList.contains('tw-hidden'))); - if (res.length > 1) throw new Error(`isElemHidden doesn't work for multiple elements`); - return res[0]; +export function hideElem(el: ElementArg): ArrayLikeIterable<Element> { + return toggleElem(el, false); } function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> { @@ -87,7 +82,7 @@ export function queryElemChildren<T extends Element>(parent: Element | ParentNod } // it works like parent.querySelectorAll: all descendants are selected -// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent +// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent if the targets are not for page-level components. export function queryElems<T extends HTMLElement>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback<T>): ArrayLikeIterable<T> { return applyElemsCallback<T>(parent.querySelectorAll(selector), fn); } @@ -166,6 +161,7 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = function resizeToFit() { if (isUserResized) return; if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return; + const previousMargin = textarea.style.marginBottom; try { const {top, bottom} = overflowOffset(); @@ -181,6 +177,9 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = const curHeight = parseFloat(computedStyle.height); const maxHeight = curHeight + bottom - adjustedViewportMarginBottom; + // In Firefox, setting auto height momentarily may cause the page to scroll up + // unexpectedly, prevent this by setting a temporary margin. + textarea.style.marginBottom = `${textarea.clientHeight}px`; textarea.style.height = 'auto'; let newHeight = textarea.scrollHeight + borderAddOn; @@ -201,6 +200,12 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = textarea.style.height = `${newHeight}px`; lastStyleHeight = textarea.style.height; } finally { + // restore previous margin + if (previousMargin) { + textarea.style.marginBottom = previousMargin; + } else { + textarea.style.removeProperty('margin-bottom'); + } // ensure that the textarea is fully scrolled to the end, when the cursor // is at the end during an input event if (textarea.selectionStart === textarea.selectionEnd && @@ -273,14 +278,12 @@ export function initSubmitEventPolyfill() { document.body.addEventListener('focus', submitEventPolyfillListener); } -/** - * Check if an element is visible, equivalent to jQuery's `:visible` pseudo. - * Note: This function doesn't account for all possible visibility scenarios. - */ -export function isElemVisible(element: HTMLElement): boolean { - if (!element) return false; - // checking element.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout - return Boolean((element.offsetWidth || element.offsetHeight || element.getClientRects().length) && element.style.display !== 'none'); +export function isElemVisible(el: HTMLElement): boolean { + // Check if an element is visible, equivalent to jQuery's `:visible` pseudo. + // This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem" + if (!el) return false; + // checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout + return !el.classList.contains('tw-hidden') && Boolean((el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none'); } // replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this @@ -311,6 +314,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T { htmlString = htmlString.trim(); // some tags like "tr" are special, it must use a correct parent container to create + // eslint-disable-next-line github/unescaped-html-literal -- FIXME: maybe we need to use other approaches to create elements from HTML, e.g. using DOMParser if (htmlString.startsWith('<tr')) { const container = document.createElement('table'); container.innerHTML = htmlString; @@ -358,7 +362,16 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable<void>, options?: boolean | AddEventListenerOptions) { parent.addEventListener(type, (e: Event) => { const elem = (e.target as HTMLElement).closest(selector); - if (!elem) return; + // It strictly checks "parent contains the target elem" to avoid side effects of selector running on outside the parent. + // Keep in mind that the elem could have been removed from parent by other event handlers before this event handler is called. + // For example: tippy popup item, the tippy popup could be hidden and removed from DOM before this. + // It is caller's responsibility make sure the elem is still in parent's DOM when this event handler is called. + if (!elem || (parent !== document && !parent.contains(elem))) return; listener(elem as T, e as E); }, options); } + +/** Returns whether a click event is a left-click without any modifiers held */ +export function isPlainClick(e: MouseEvent) { + return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; +} diff --git a/web_src/js/utils/filetree.test.ts b/web_src/js/utils/filetree.test.ts deleted file mode 100644 index f561cb75f0..0000000000 --- a/web_src/js/utils/filetree.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import {mergeChildIfOnlyOneDir, pathListToTree, type File} from './filetree.ts'; - -const emptyList: File[] = []; -const singleFile = [{Name: 'file1'}] as File[]; -const singleDir = [{Name: 'dir1/file1'}] as File[]; -const nestedDir = [{Name: 'dir1/dir2/file1'}] as File[]; -const multiplePathsDisjoint = [{Name: 'dir1/dir2/file1'}, {Name: 'dir3/file2'}] as File[]; -const multiplePathsShared = [{Name: 'dir1/dir2/dir3/file1'}, {Name: 'dir1/file2'}] as File[]; - -test('pathListToTree', () => { - expect(pathListToTree(emptyList)).toEqual([]); - expect(pathListToTree(singleFile)).toEqual([ - {isFile: true, name: 'file1', path: 'file1', file: {Name: 'file1'}}, - ]); - expect(pathListToTree(singleDir)).toEqual([ - {isFile: false, name: 'dir1', path: 'dir1', children: [ - {isFile: true, name: 'file1', path: 'dir1/file1', file: {Name: 'dir1/file1'}}, - ]}, - ]); - expect(pathListToTree(nestedDir)).toEqual([ - {isFile: false, name: 'dir1', path: 'dir1', children: [ - {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [ - {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}}, - ]}, - ]}, - ]); - expect(pathListToTree(multiplePathsDisjoint)).toEqual([ - {isFile: false, name: 'dir1', path: 'dir1', children: [ - {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [ - {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}}, - ]}, - ]}, - {isFile: false, name: 'dir3', path: 'dir3', children: [ - {isFile: true, name: 'file2', path: 'dir3/file2', file: {Name: 'dir3/file2'}}, - ]}, - ]); - expect(pathListToTree(multiplePathsShared)).toEqual([ - {isFile: false, name: 'dir1', path: 'dir1', children: [ - {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [ - {isFile: false, name: 'dir3', path: 'dir1/dir2/dir3', children: [ - {isFile: true, name: 'file1', path: 'dir1/dir2/dir3/file1', file: {Name: 'dir1/dir2/dir3/file1'}}, - ]}, - ]}, - {isFile: true, name: 'file2', path: 'dir1/file2', file: {Name: 'dir1/file2'}}, - ]}, - ]); -}); - -const mergeChildWrapper = (testCase: File[]) => { - const tree = pathListToTree(testCase); - mergeChildIfOnlyOneDir(tree); - return tree; -}; - -test('mergeChildIfOnlyOneDir', () => { - expect(mergeChildWrapper(emptyList)).toEqual([]); - expect(mergeChildWrapper(singleFile)).toEqual([ - {isFile: true, name: 'file1', path: 'file1', file: {Name: 'file1'}}, - ]); - expect(mergeChildWrapper(singleDir)).toEqual([ - {isFile: false, name: 'dir1', path: 'dir1', children: [ - {isFile: true, name: 'file1', path: 'dir1/file1', file: {Name: 'dir1/file1'}}, - ]}, - ]); - expect(mergeChildWrapper(nestedDir)).toEqual([ - {isFile: false, name: 'dir1/dir2', path: 'dir1/dir2', children: [ - {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}}, - ]}, - ]); - expect(mergeChildWrapper(multiplePathsDisjoint)).toEqual([ - {isFile: false, name: 'dir1/dir2', path: 'dir1/dir2', children: [ - {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}}, - ]}, - {isFile: false, name: 'dir3', path: 'dir3', children: [ - {isFile: true, name: 'file2', path: 'dir3/file2', file: {Name: 'dir3/file2'}}, - ]}, - ]); - expect(mergeChildWrapper(multiplePathsShared)).toEqual([ - {isFile: false, name: 'dir1', path: 'dir1', children: [ - {isFile: false, name: 'dir2/dir3', path: 'dir1/dir2/dir3', children: [ - {isFile: true, name: 'file1', path: 'dir1/dir2/dir3/file1', file: {Name: 'dir1/dir2/dir3/file1'}}, - ]}, - {isFile: true, name: 'file2', path: 'dir1/file2', file: {Name: 'dir1/file2'}}, - ]}, - ]); -}); diff --git a/web_src/js/utils/filetree.ts b/web_src/js/utils/filetree.ts deleted file mode 100644 index 35f9f58189..0000000000 --- a/web_src/js/utils/filetree.ts +++ /dev/null @@ -1,85 +0,0 @@ -import {dirname, basename} from '../utils.ts'; - -export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange'; - -export type File = { - Name: string; - NameHash: string; - Status: FileStatus; - IsViewed: boolean; - IsSubmodule: boolean; -} - -type DirItem = { - isFile: false; - name: string; - path: string; - - children: Item[]; -} - -type FileItem = { - isFile: true; - name: string; - path: string; - file: File; -} - -export type Item = DirItem | FileItem; - -export function pathListToTree(fileEntries: File[]): Item[] { - const pathToItem = new Map<string, DirItem>(); - - // init root node - const root: DirItem = {name: '', path: '', isFile: false, children: []}; - pathToItem.set('', root); - - for (const fileEntry of fileEntries) { - const [parentPath, fileName] = [dirname(fileEntry.Name), basename(fileEntry.Name)]; - - let parentItem = pathToItem.get(parentPath); - if (!parentItem) { - parentItem = constructParents(pathToItem, parentPath); - } - - const fileItem: FileItem = {name: fileName, path: fileEntry.Name, isFile: true, file: fileEntry}; - - parentItem.children.push(fileItem); - } - - return root.children; -} - -function constructParents(pathToItem: Map<string, DirItem>, dirPath: string): DirItem { - const [dirParentPath, dirName] = [dirname(dirPath), basename(dirPath)]; - - let parentItem = pathToItem.get(dirParentPath); - if (!parentItem) { - // if the parent node does not exist, create it - parentItem = constructParents(pathToItem, dirParentPath); - } - - const dirItem: DirItem = {name: dirName, path: dirPath, isFile: false, children: []}; - parentItem.children.push(dirItem); - pathToItem.set(dirPath, dirItem); - - return dirItem; -} - -export function mergeChildIfOnlyOneDir(nodes: Item[]): void { - for (const node of nodes) { - if (node.isFile) { - continue; - } - const dir = node as DirItem; - - mergeChildIfOnlyOneDir(dir.children); - - if (dir.children.length === 1 && dir.children[0].isFile === false) { - const child = dir.children[0]; - dir.name = `${dir.name}/${child.name}`; - dir.path = child.path; - dir.children = child.children; - } - } -} diff --git a/web_src/js/utils/html.test.ts b/web_src/js/utils/html.test.ts new file mode 100644 index 0000000000..3028b7bb0a --- /dev/null +++ b/web_src/js/utils/html.test.ts @@ -0,0 +1,8 @@ +import {html, htmlEscape, htmlRaw} from './html.ts'; + +test('html', async () => { + expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a><>&'"</a>`); + expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`); + expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &></a>`); + expect(htmlEscape(`<a></a>`)).toBe(`<a></a>`); +}); diff --git a/web_src/js/utils/html.ts b/web_src/js/utils/html.ts new file mode 100644 index 0000000000..22e5703c34 --- /dev/null +++ b/web_src/js/utils/html.ts @@ -0,0 +1,32 @@ +export function htmlEscape(s: string, ...args: Array<any>): string { + if (args.length !== 0) throw new Error('use html or htmlRaw instead of htmlEscape'); // check legacy usages + return s.replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +class rawObject { + private readonly value: string; + constructor(v: string) { this.value = v } + toString(): string { return this.value } +} + +export function html(tmpl: TemplateStringsArray, ...parts: Array<any>): string { + let output = tmpl[0]; + for (let i = 0; i < parts.length; i++) { + const value = parts[i]; + const valueEscaped = (value instanceof rawObject) ? value.toString() : htmlEscape(String(parts[i])); + output = output + valueEscaped + tmpl[i + 1]; + } + return output; +} + +export function htmlRaw(s: string|TemplateStringsArray, ...tmplParts: Array<any>): rawObject { + if (typeof s === 'string') { + if (tmplParts.length !== 0) throw new Error("either htmlRaw('str') or htmlRaw`tmpl`"); + return new rawObject(s); + } + return new rawObject(html(s, ...tmplParts)); +} diff --git a/web_src/js/webcomponents/overflow-menu.ts b/web_src/js/webcomponents/overflow-menu.ts index 4e729a268a..ae93f2b758 100644 --- a/web_src/js/webcomponents/overflow-menu.ts +++ b/web_src/js/webcomponents/overflow-menu.ts @@ -1,6 +1,6 @@ import {throttle} from 'throttle-debounce'; import {createTippy} from '../modules/tippy.ts'; -import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; +import {addDelegatedEventListener, isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg'; window.customElements.define('overflow-menu', class extends HTMLElement { @@ -12,10 +12,14 @@ window.customElements.define('overflow-menu', class extends HTMLElement { mutationObserver: MutationObserver; lastWidth: number; + updateButtonActivationState() { + if (!this.button || !this.tippyContent) return; + this.button.classList.toggle('active', Boolean(this.tippyContent.querySelector('.item.active'))); + } + updateItems = throttle(100, () => { if (!this.tippyContent) { const div = document.createElement('div'); - div.classList.add('tippy-target'); div.tabIndex = -1; // for initial focus, programmatic focus only div.addEventListener('keydown', (e) => { if (e.key === 'Tab') { @@ -64,9 +68,10 @@ window.customElements.define('overflow-menu', class extends HTMLElement { } } }); - this.append(div); + div.classList.add('tippy-target'); + this.handleItemClick(div, '.tippy-target > .item'); this.tippyContent = div; - } + } // end if: no tippyContent and create a new one const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space'); const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button'); @@ -88,7 +93,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { const menuRight = this.offsetLeft + this.offsetWidth; const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space'); let afterFlexSpace = false; - for (const item of menuItems) { + for (const [idx, item] of menuItems.entries()) { if (item.classList.contains('item-flex-space')) { afterFlexSpace = true; continue; @@ -96,7 +101,10 @@ 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 - this.tippyItems.push(item); + const onlyLastItem = idx === menuItems.length - 1 && this.tippyItems.length === 0; + const lastItemFit = onlyLastItem && menuRight - itemRight > 0; + const moveToPopup = !onlyLastItem || !lastItemFit; + if (moveToPopup) this.tippyItems.push(item); } } itemFlexSpace?.style.removeProperty('display'); @@ -107,6 +115,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { const btn = this.querySelector('.overflow-menu-button'); btn?._tippy?.destroy(); btn?.remove(); + this.button = null; return; } @@ -126,18 +135,17 @@ window.customElements.define('overflow-menu', class extends HTMLElement { // update existing tippy if (this.button?._tippy) { this.button._tippy.setContent(this.tippyContent); + this.updateButtonActivationState(); return; } // create button initially - const btn = document.createElement('button'); - btn.classList.add('overflow-menu-button'); - btn.setAttribute('aria-label', window.config.i18n.more_items); - btn.innerHTML = octiconKebabHorizontal; - this.append(btn); - this.button = btn; - - createTippy(btn, { + this.button = document.createElement('button'); + this.button.classList.add('overflow-menu-button'); + this.button.setAttribute('aria-label', window.config.i18n.more_items); + this.button.innerHTML = octiconKebabHorizontal; + this.append(this.button); + createTippy(this.button, { trigger: 'click', hideOnClick: true, interactive: true, @@ -151,6 +159,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { }, 0); }, }); + this.updateButtonActivationState(); }); init() { @@ -187,6 +196,14 @@ window.customElements.define('overflow-menu', class extends HTMLElement { } }); this.resizeObserver.observe(this); + this.handleItemClick(this, '.overflow-menu-items > .item'); + } + + handleItemClick(el: Element, selector: string) { + addDelegatedEventListener(el, 'click', selector, () => { + this.button?._tippy?.hide(); + this.updateButtonActivationState(); + }); } connectedCallback() { diff --git a/web_src/js/webcomponents/polyfill.test.ts b/web_src/js/webcomponents/polyfill.test.ts new file mode 100644 index 0000000000..4fb4621547 --- /dev/null +++ b/web_src/js/webcomponents/polyfill.test.ts @@ -0,0 +1,7 @@ +import {weakRefClass} from './polyfills.ts'; + +test('polyfillWeakRef', () => { + const WeakRef = weakRefClass(); + const r = new WeakRef(123); + expect(r.deref()).toEqual(123); +}); diff --git a/web_src/js/webcomponents/polyfills.ts b/web_src/js/webcomponents/polyfills.ts index 4a84ee9562..9575324b5a 100644 --- a/web_src/js/webcomponents/polyfills.ts +++ b/web_src/js/webcomponents/polyfills.ts @@ -16,3 +16,19 @@ try { return intlNumberFormat(locales, options); }; } + +export function weakRefClass() { + const weakMap = new WeakMap(); + return class { + constructor(target: any) { + weakMap.set(this, target); + } + deref() { + return weakMap.get(this); + } + }; +} + +if (!window.WeakRef) { + window.WeakRef = weakRefClass() as any; +} |