Merge branch 'upstream_main' into add-file-tree-to-file-view-page

This commit is contained in:
Kerwin Bryant 2025-03-04 00:23:35 +00:00
commit 775d66bd33
39 changed files with 246 additions and 266 deletions

View File

@ -9,7 +9,9 @@ import (
"net/url"
"strings"
user_model "code.gitea.io/gitea/models/user"
webhook_model "code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
@ -308,12 +310,16 @@ func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, w
}
func getStatusPayloadInfo(p *api.CommitStatusPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) {
refLink := linkFormatter(p.TargetURL, p.Context+"["+p.SHA+"]:"+p.Description)
refLink := linkFormatter(p.TargetURL, fmt.Sprintf("%s [%s]", p.Context, base.ShortSha(p.SHA)))
text = fmt.Sprintf("Commit Status changed: %s", refLink)
text = fmt.Sprintf("Commit Status changed: %s - %s", refLink, p.Description)
color = greenColor
if withSender {
text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
if user_model.IsGiteaActionsUserName(p.Sender.UserName) {
text += fmt.Sprintf(" by %s", p.Sender.FullName)
} else {
text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
}
}
return text, color

View File

@ -15,6 +15,7 @@ import (
"strings"
webhook_model "code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
@ -245,8 +246,8 @@ func (m matrixConvertor) Package(p *api.PackagePayload) (MatrixPayload, error) {
}
func (m matrixConvertor) Status(p *api.CommitStatusPayload) (MatrixPayload, error) {
refLink := htmlLinkFormatter(p.TargetURL, p.Context+"["+p.SHA+"]:"+p.Description)
text := fmt.Sprintf("Commit Status changed: %s", refLink)
refLink := htmlLinkFormatter(p.TargetURL, fmt.Sprintf("%s [%s]", p.Context, base.ShortSha(p.SHA)))
text := fmt.Sprintf("Commit Status changed: %s - %s", refLink, p.Description)
return m.newPayload(text)
}

View File

@ -6,7 +6,7 @@
<div class="ui mobile reversed stackable grid">
<div class="ui {{if .ShowMemberAndTeamTab}}eleven wide{{end}} column">
{{if .ProfileReadmeContent}}
<div id="readme_profile" class="markup" data-profile-view-as-member="{{.IsViewingOrgAsMember}}">{{.ProfileReadmeContent}}</div>
<div id="readme_profile" class="render-content markup" data-profile-view-as-member="{{.IsViewingOrgAsMember}}">{{.ProfileReadmeContent}}</div>
{{end}}
{{template "shared/repo_search" .}}
{{template "explore/repo_list" .}}

View File

@ -74,9 +74,7 @@
{{end}}
</div>
{{if .Description}}
<div class="content">
{{.RenderedContent}}
</div>
<div class="render-content markup">{{.RenderedContent}}</div>
{{end}}
</li>
{{end}}

View File

@ -14,7 +14,8 @@
Search "repo/branch_dropdown" in the template directory to find all occurrences.
*/}}
<div class="js-branch-tag-selector {{if .ContainerClasses}}{{.ContainerClasses}}{{end}}"
<div class="{{if .ContainerClasses}}{{.ContainerClasses}}{{end}}"
data-global-init="initRepoBranchTagSelector"
data-text-release-compare="{{ctx.Locale.Tr "repo.release.compare"}}"
data-text-branches="{{ctx.Locale.Tr "repo.branches"}}"
data-text-tags="{{ctx.Locale.Tr "repo.tags"}}"

View File

@ -48,7 +48,7 @@
{{end}}
</span>
{{if IsMultilineCommitMessage .Message}}
<button class="ui button js-toggle-commit-body ellipsis-button" aria-expanded="false">...</button>
<button class="ui button ellipsis-button" aria-expanded="false" data-global-click="onRepoEllipsisButtonClick">...</button>
{{end}}
{{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}}
{{if IsMultilineCommitMessage .Message}}

View File

@ -45,10 +45,10 @@
data-line-wrap-extensions="{{.LineWrapExtensions}}">{{.FileContent}}</textarea>
<div class="editor-loading is-loading"></div>
</div>
<div class="ui tab markup tw-px-4 tw-py-3" data-tab="preview">
<div class="ui tab tw-px-4 tw-py-3" data-tab="preview">
{{ctx.Locale.Tr "loading"}}
</div>
<div class="ui tab diff edit-diff" data-tab="diff">
<div class="ui tab" data-tab="diff">
<div class="tw-p-16"></div>
</div>
</div>

View File

@ -1,3 +1,3 @@
<div class="field {{if not .item.VisibleOnForm}}tw-hidden{{end}}">
<div class="markup">{{ctx.RenderUtils.MarkdownToHtml .item.Attributes.value}}</div>
<div class="render-content markup">{{ctx.RenderUtils.MarkdownToHtml .item.Attributes.value}}</div>
</div>

View File

@ -22,7 +22,7 @@
{{end}}
</div>
{{if .Milestone.RenderedContent}}
<div class="markup content tw-mb-4">
<div class="render-content markup tw-mb-4">
{{.Milestone.RenderedContent}}
</div>
{{end}}

View File

@ -81,9 +81,7 @@
{{end}}
</div>
{{if .Content}}
<div class="markup content">
{{.RenderedContent}}
</div>
<div class="render-content markup">{{.RenderedContent}}</div>
{{end}}
</li>
{{end}}

View File

@ -23,7 +23,7 @@
{{$commitLink:= printf "%s/commit/%s" .RepoLink (PathEscape .LatestCommit.ID.String)}}
<span class="grey commit-summary" title="{{.LatestCommit.Summary}}"><span class="message-wrapper">{{ctx.RenderUtils.RenderCommitMessageLinkSubject .LatestCommit.Message $commitLink ($.Repository.ComposeMetas ctx)}}</span>
{{if IsMultilineCommitMessage .LatestCommit.Message}}
<button class="ui button js-toggle-commit-body ellipsis-button" aria-expanded="false">...</button>
<button class="ui button ellipsis-button" aria-expanded="false" data-global-click="onRepoEllipsisButtonClick">...</button>
<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody .LatestCommit.Message ($.Repository.ComposeMetas ctx)}}</pre>
{{end}}
</span>

View File

@ -21,6 +21,7 @@
{{$compareTarget = $release.Sha1}}
{{end}}
{{template "repo/branch_dropdown" dict
"ContainerClasses" "release-branch-tag-selector"
"Repository" $.Repository
"ShowTabTags" true
"DropdownFixedText" (ctx.Locale.Tr "repo.release.compare")
@ -64,7 +65,7 @@
| <span class="ahead"><a href="{{$.RepoLink}}/compare/{{$release.TagName | PathEscapeSegments}}...{{$release.TargetBehind | PathEscapeSegments}}">{{ctx.Locale.Tr "repo.release.ahead.commits" $release.NumCommitsBehind}}</a> {{ctx.Locale.Tr "repo.release.ahead.target" $release.TargetBehind}}</span>
{{end}}
</p>
<div class="markup desc">
<div class="render-content markup">
{{$release.RenderedNote}}
</div>
<div class="divider"></div>

View File

@ -13,7 +13,7 @@
</h4>
<div class="ui bottom attached table unstackable segment">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
<div class="file-view{{if .IsMarkup}} markup {{.MarkupType}}{{else if .IsPlainText}} plain-text{{else if .IsTextFile}} code-view{{end}}">
<div class="file-view {{if .IsPlainText}}plain-text{{else if .IsTextFile}}code-view{{end}}">
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
{{else if not .FileSize}}
@ -31,7 +31,7 @@
<strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong>
</audio>
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "diff.view_file"}}"></div>
<div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "diff.view_file"}}"></div>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}

View File

@ -55,7 +55,7 @@
{{end}}
</div>
<a download class="btn-octicon" data-tooltip-content="{{ctx.Locale.Tr "repo.download_file"}}" href="{{$.RawFileLink}}">{{svg "octicon-download"}}</a>
<a id="copy-content" class="btn-octicon {{if not .CanCopyContent}} disabled{{end}}"{{if or .IsImageFile (and .HasSourceRenderedToggle (not .IsDisplayingSource))}} data-link="{{$.RawFileLink}}"{{end}} data-tooltip-content="{{if .CanCopyContent}}{{ctx.Locale.Tr "copy_content"}}{{else}}{{ctx.Locale.Tr "copy_type_unsupported"}}{{end}}">{{svg "octicon-copy"}}</a>
<a class="btn-octicon {{if not .CanCopyContent}} disabled{{end}}" data-global-click="onCopyContentButtonClick" {{if or .IsImageFile (and .HasSourceRenderedToggle (not .IsDisplayingSource))}} data-link="{{$.RawFileLink}}"{{end}} data-tooltip-content="{{if .CanCopyContent}}{{ctx.Locale.Tr "copy_content"}}{{else}}{{ctx.Locale.Tr "copy_type_unsupported"}}{{end}}">{{svg "octicon-copy"}}</a>
{{if .EnableFeed}}
<a class="btn-octicon" href="{{$.RepoLink}}/rss/{{$.RefTypeNameSubURL}}/{{PathEscapeSegments .TreePath}}" data-tooltip-content="{{ctx.Locale.Tr "rss_feed"}}">
{{svg "octicon-rss"}}
@ -108,7 +108,7 @@
<strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong>
</audio>
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
<div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}

View File

@ -63,18 +63,18 @@
<div class="wiki-content-parts">
{{if .sidebarTocContent}}
<div class="markup wiki-content-sidebar wiki-content-toc">
<div class="render-content markup wiki-content-sidebar wiki-content-toc">
{{.sidebarTocContent | SafeHTML}}
</div>
{{end}}
<div class="markup wiki-content-main {{if or .sidebarTocContent .sidebarPresent}}with-sidebar{{end}}">
<div class="render-content markup wiki-content-main {{if or .sidebarTocContent .sidebarPresent}}with-sidebar{{end}}">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
{{.content | SafeHTML}}
</div>
{{if .sidebarPresent}}
<div class="markup wiki-content-sidebar">
<div class="render-content markup wiki-content-sidebar">
{{if and .CanWriteWiki (not .Repository.IsMirror)}}
<a class="tw-float-right muted" href="{{.RepoLink}}/wiki/_Sidebar?action=_edit" aria-label="{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}">{{svg "octicon-pencil"}}</a>
{{end}}
@ -86,7 +86,7 @@
<div class="tw-clear-both"></div>
{{if .footerPresent}}
<div class="markup wiki-content-footer">
<div class="render-content markup wiki-content-footer">
{{if and .CanWriteWiki (not .Repository.IsMirror)}}
<a class="tw-float-right muted" href="{{.RepoLink}}/wiki/_Footer?action=_edit" aria-label="{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}">{{svg "octicon-pencil"}}</a>
{{end}}

View File

@ -81,7 +81,7 @@
}
</script>
</div>
<div class="ui tab markup" data-tab-panel="markdown-previewer">
<div class="ui tab" data-tab-panel="markdown-previewer">
{{ctx.Locale.Tr "loading"}}
</div>
<div class="markdown-add-table-panel tippy-target">

View File

@ -110,7 +110,7 @@
<a href="{{.GetCommentLink ctx}}" class="text truncate issue title">{{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}}</a>
{{$comment := index .GetIssueInfos 1}}
{{if $comment}}
<div class="markup tw-text-14">{{ctx.RenderUtils.MarkdownToHtml $comment}}</div>
<div class="render-content markup tw-text-14">{{ctx.RenderUtils.MarkdownToHtml $comment}}</div>
{{end}}
{{else if .GetOpType.InActions "merge_pull_request"}}
<div class="flex-item-body text black">{{index .GetIssueInfos 1}}</div>

View File

@ -33,7 +33,7 @@
{{end}}
</div>
</div>
<div class="flex-container-main content">
<div class="flex-container-main">
<div class="list-header">
<div class="small-menu-items ui compact tiny menu list-header-toggle">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
@ -140,9 +140,7 @@
{{end}}
</div>
{{if .Content}}
<div class="markup content">
{{.RenderedContent}}
</div>
<div class="render-content markup">{{.RenderedContent}}</div>
{{end}}
</li>
{{end}}

View File

@ -26,7 +26,7 @@
{{else if eq .TabName "followers"}}
{{template "repo/user_cards" .}}
{{else if eq .TabName "overview"}}
<div id="readme_profile" class="markup">{{.ProfileReadmeContent}}</div>
<div id="readme_profile" class="render-content markup">{{.ProfileReadmeContent}}</div>
{{else if eq .TabName "organizations"}}
{{template "repo/user_cards" .}}
{{else}}

View File

@ -74,12 +74,3 @@
padding: 1rem;
text-align: center;
}
.edit-diff {
padding: 0 !important;
}
.edit-diff > div > .ui.table {
border-top: none !important;
border-bottom: none !important;
}

View File

@ -535,7 +535,7 @@
user-select: none;
}
.markup-render {
.markup-content-iframe {
display: block;
border: none;
width: 100%;

View File

@ -45,7 +45,7 @@
display: flex;
align-items: center;
}
#release-list .js-branch-tag-selector {
#release-list .release-branch-tag-selector {
margin-left: auto;
}
#release-list .branch-selector-dropdown .menu { /* open menu to left */

View File

@ -12,7 +12,7 @@
border-top: 1px solid var(--color-secondary);
}
.milestone-card .content {
.milestone-card .render-content {
padding-top: 10px;
}

View File

@ -1,5 +1,5 @@
import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, queryElems, showElem, toggleElem} from '../utils/dom.ts';
import {addDelegatedEventListener, hideElem, showElem, toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {camelize} from 'vue';
@ -74,10 +74,9 @@ export function initGlobalDeleteButton(): void {
}
}
function onShowPanelClick(e: MouseEvent) {
function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
const el = e.currentTarget as HTMLElement;
e.preventDefault();
const sel = el.getAttribute('data-panel');
if (el.classList.contains('toggle')) {
@ -87,9 +86,8 @@ function onShowPanelClick(e: MouseEvent) {
}
}
function onHidePanelClick(e: MouseEvent) {
function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
const el = e.currentTarget as HTMLElement;
e.preventDefault();
let sel = el.getAttribute('data-panel');
if (sel) {
@ -104,7 +102,7 @@ function onHidePanelClick(e: MouseEvent) {
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
}
function onShowModalClick(e: MouseEvent) {
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.
// * First, try to query '#target'
@ -112,7 +110,6 @@ function onShowModalClick(e: MouseEvent) {
// * 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.
const el = e.currentTarget as HTMLElement;
e.preventDefault();
const modalSelector = el.getAttribute('data-modal');
const elModal = document.querySelector(modalSelector);
@ -160,7 +157,15 @@ export function initGlobalButtons(): void {
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
queryElems(document, '.show-panel', (el) => el.addEventListener('click', onShowPanelClick));
queryElems(document, '.hide-panel', (el) => el.addEventListener('click', onHidePanelClick));
queryElems(document, '.show-modal', (el) => el.addEventListener('click', onShowModalClick));
// Ideally these "button" events should be handled by registerGlobalEventFunc
// Refactoring would involve too many changes, so at the moment, just use the global event listener.
addDelegatedEventListener(document, 'click', '.show-panel, .hide-panel, .show-modal', (el, e: MouseEvent) => {
if (el.classList.contains('show-panel')) {
onShowPanelClick(el, e);
} else if (el.classList.contains('hide-panel')) {
onHidePanelClick(el, e);
} else if (el.classList.contains('show-modal')) {
onShowModalClick(el, e);
}
});
}

View File

@ -2,21 +2,19 @@ import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {convertImage} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
const {i18n} = window.config;
export function initCopyContent() {
const btn = document.querySelector('#copy-content');
if (!btn || btn.classList.contains('disabled')) return;
btn.addEventListener('click', async () => {
if (btn.classList.contains('is-loading')) return;
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLInputElement) => {
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
let content;
let isRasterImage = false;
const link = btn.getAttribute('data-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
// the text to copy is not in the DOM, or it is an image which should be
// fetched to copy in full resolution
if (link) {
btn.classList.add('is-loading', 'loading-icon-2px');
@ -40,7 +38,7 @@ export function initCopyContent() {
content = Array.from(lineEls, (el) => el.textContent).join('');
}
// try copy original first, if that fails and it's an image, convert it to png
// try copy original first, if that fails, and it's an image, convert it to png
const success = await clippie(content);
if (success) {
showTemporaryTooltip(btn, i18n.copy_success);

View File

@ -1,15 +1,14 @@
import {createTippy} from '../modules/tippy.ts';
import {toggleElem} from '../utils/dom.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
export function initRepoEllipsisButton() {
for (const button of document.querySelectorAll<HTMLButtonElement>('.js-toggle-commit-body')) {
button.addEventListener('click', function (e) {
e.preventDefault();
const expanded = this.getAttribute('aria-expanded') === 'true';
toggleElem(this.parentElement.querySelector('.commit-body'));
this.setAttribute('aria-expanded', String(!expanded));
});
}
registerGlobalEventFunc('click', 'onRepoEllipsisButtonClick', async (el: HTMLInputElement, e: Event) => {
e.preventDefault();
const expanded = el.getAttribute('aria-expanded') === 'true';
toggleElem(el.parentElement.querySelector('.commit-body'));
el.setAttribute('aria-expanded', String(!expanded));
});
}
export function initCommitStatuses() {

View File

@ -1,7 +1,6 @@
import {htmlEscape} from 'escape-goat';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
import {initMarkupContent} from '../markup/content.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
@ -199,7 +198,6 @@ export function initRepoEditor() {
}
export function renderPreviewPanelContent(previewPanel: Element, content: string) {
previewPanel.innerHTML = content;
initMarkupContent();
previewPanel.innerHTML = `<div class="render-content markup">${content}</div>`;
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
}

View File

@ -4,7 +4,6 @@ import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {hideElem, querySingleVisibleElem, showElem, type DOMEvent} from '../utils/dom.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
import {initCommentContent, initMarkupContent} from '../markup/content.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
@ -74,8 +73,6 @@ async function tryOnEditContent(e: DOMEvent<MouseEvent>) {
content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
}
comboMarkdownEditor.dropzoneSubmitReload();
initMarkupContent();
initCommentContent();
} catch (error) {
showErrorToast(`Failed to save the content: ${error}`);
console.error(error);

View File

@ -1,3 +1,4 @@
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {
initRepoCommentFormAndSidebar,
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
@ -20,10 +21,10 @@ import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
function initRepoBranchTagSelector(selector: string) {
for (const elRoot of document.querySelectorAll(selector)) {
function initRepoBranchTagSelector() {
registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
createApp(RepoBranchTagSelector, {elRoot}).mount(elRoot);
}
});
}
export function initBranchSelectorTabs() {
@ -42,7 +43,7 @@ export function initRepository() {
const pageContent = document.querySelector('.page-content.repository');
if (!pageContent) return;
initRepoBranchTagSelector('.js-branch-tag-selector');
initRepoBranchTagSelector();
initRepoCommentFormAndSidebar();
// Labels

View File

@ -1,4 +1,3 @@
import {initMarkupContent} from '../markup/content.ts';
import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {fomanticMobileScreen} from '../modules/fomantic.ts';
import {POST} from '../modules/fetch.ts';
@ -31,8 +30,7 @@ async function initRepoWikiFormEditor() {
const response = await POST(editor.previewUrl, {data: formData});
const data = await response.text();
lastContent = newContent;
previewTarget.innerHTML = `<div class="markup ui segment">${data}</div>`;
initMarkupContent();
previewTarget.innerHTML = `<div class="render-content markup ui segment">${data}</div>`;
} catch (error) {
console.error('Error rendering preview:', error);
} finally {

View File

@ -18,7 +18,7 @@ import {initNotificationCount, initNotificationsTable} from './features/notifica
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts';
import {initFindFileInRepo} from './features/repo-findfile.ts';
import {initCommentContent, initMarkupContent} from './markup/content.ts';
import {initMarkupContent} from './markup/content.ts';
import {initPdfViewer} from './render/pdf.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
@ -103,7 +103,6 @@ onDomReady(() => {
initHeadNavbarContentToggle,
initFootLanguageMenu,
initCommentContent,
initContextPopups,
initHeatmap,
initImageDiff,

View File

@ -1,6 +1,6 @@
export async function renderAsciicast() {
const els = document.querySelectorAll('.asciinema-player-container');
if (!els.length) return;
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
const el = elMarkup.querySelector('.asciinema-player-container');
if (!el) return;
const [player] = await Promise.all([
// @ts-expect-error: module exports no types
@ -8,11 +8,9 @@ export async function renderAsciicast() {
import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
]);
for (const el of els) {
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',
});
}

View File

@ -7,15 +7,12 @@ export function makeCodeCopyButton(): HTMLButtonElement {
return button;
}
export function renderCodeCopy(): void {
const els = document.querySelectorAll('.markup .code-block code');
if (!els.length) return;
export function initMarkupCodeCopy(elMarkup: HTMLElement): void {
const el = elMarkup.querySelector('.code-block code'); // .markup .code-block code
if (!el || !el.textContent) return;
for (const el of els) {
if (!el.textContent) continue;
const btn = makeCodeCopyButton();
// remove final trailing newline introduced during HTML rendering
btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, ''));
el.after(btn);
}
const btn = makeCodeCopyButton();
// remove final trailing newline introduced during HTML rendering
btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, ''));
el.after(btn);
}

View File

@ -1,18 +1,17 @@
import {renderMermaid} from './mermaid.ts';
import {renderMath} from './math.ts';
import {renderCodeCopy} from './codecopy.ts';
import {renderAsciicast} from './asciicast.ts';
import {initMarkupCodeMermaid} from './mermaid.ts';
import {initMarkupCodeMath} from './math.ts';
import {initMarkupCodeCopy} from './codecopy.ts';
import {initMarkupRenderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
// code that runs for all markup content
export function initMarkupContent(): void {
renderMermaid();
renderMath();
renderCodeCopy();
renderAsciicast();
}
// code that only runs for comments
export function initCommentContent(): void {
initMarkupTasklist();
registerGlobalSelectorFunc('.markup', (el: HTMLElement) => {
initMarkupCodeCopy(el);
initMarkupTasklist(el);
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
initMarkupRenderAsciicast(el);
});
}

View File

@ -11,9 +11,9 @@ function targetElement(el: Element): {target: Element, displayAsBlock: boolean}
};
}
export async function renderMath(): Promise<void> {
const els = document.querySelectorAll('.markup code.language-math');
if (!els.length) return;
export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> {
const el = elMarkup.querySelector('code.language-math'); // .markup code.language-math'
if (!el) return;
const [{default: katex}] = await Promise.all([
import(/* webpackChunkName: "katex" */'katex'),
@ -24,25 +24,23 @@ export async function renderMath(): Promise<void> {
const MAX_SIZE = 25;
const MAX_EXPAND = 1000;
for (const el of els) {
const {target, displayAsBlock} = targetElement(el);
if (target.hasAttribute('data-render-done')) continue;
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}.`));
continue;
}
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);
}
}

View File

@ -10,9 +10,9 @@ body {margin: 0; padding: 0; overflow: hidden}
#mermaid {display: block; margin: 0 auto}
blockquote, dd, dl, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {margin: 0}`;
export async function renderMermaid(): Promise<void> {
const els = document.querySelectorAll('.markup code.language-mermaid');
if (!els.length) return;
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');
@ -23,67 +23,65 @@ export async function renderMermaid(): Promise<void> {
suppressErrorRendering: true,
});
for (const el of els) {
const pre = el.closest('pre');
if (pre.hasAttribute('data-render-done')) continue;
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}.`));
continue;
}
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);
continue;
}
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);
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-render', 'tw-invisible');
iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`;
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 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 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`;
}
};
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');
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();
setTimeout(() => { // avoid flash of iframe background
mermaidBlock.classList.remove('is-loading');
iframe.classList.remove('tw-invisible');
}, 0);
}, {root: document.documentElement})).observe(iframe);
});
// 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);
}
document.body.append(mermaidBlock);
} catch (err) {
displayError(pre, err);
}
}

View File

@ -7,80 +7,80 @@ const preventListener = (e: Event) => e.preventDefault();
* Attaches `input` handlers to markdown rendered tasklist checkboxes in comments.
*
* When a checkbox value changes, the corresponding [ ] or [x] in the markdown string
* is set accordingly and sent to the server. On success it updates the raw-content on
* is set accordingly and sent to the server. On success, it updates the raw-content on
* error it resets the checkbox to its original value.
*/
export function initMarkupTasklist(): void {
for (const el of document.querySelectorAll(`.markup[data-can-edit=true]`) || []) {
const container = el.parentNode;
const checkboxes = el.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`);
export function initMarkupTasklist(elMarkup: HTMLElement): void {
if (!elMarkup.matches('[data-can-edit=true]')) return;
for (const checkbox of checkboxes) {
if (checkbox.hasAttribute('data-editable')) {
const container = elMarkup.parentNode;
const checkboxes = elMarkup.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`);
for (const checkbox of checkboxes) {
if (checkbox.hasAttribute('data-editable')) {
return;
}
checkbox.setAttribute('data-editable', 'true');
checkbox.addEventListener('input', async () => {
const checkboxCharacter = checkbox.checked ? 'x' : ' ';
const position = parseInt(checkbox.getAttribute('data-source-position')) + 1;
const rawContent = container.querySelector('.raw-content');
const oldContent = rawContent.textContent;
const encoder = new TextEncoder();
const buffer = encoder.encode(oldContent);
// Indexes may fall off the ends and return undefined.
if (buffer[position - 1] !== '['.codePointAt(0) ||
buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) ||
buffer[position + 1] !== ']'.codePointAt(0)) {
// Position is probably wrong. Revert and don't allow change.
checkbox.checked = !checkbox.checked;
throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`);
}
buffer.set(encoder.encode(checkboxCharacter), position);
const newContent = new TextDecoder().decode(buffer);
if (newContent === oldContent) {
return;
}
checkbox.setAttribute('data-editable', 'true');
checkbox.addEventListener('input', async () => {
const checkboxCharacter = checkbox.checked ? 'x' : ' ';
const position = parseInt(checkbox.getAttribute('data-source-position')) + 1;
// Prevent further inputs until the request is done. This does not use the
// `disabled` attribute because it causes the border to flash on click.
for (const checkbox of checkboxes) {
checkbox.addEventListener('click', preventListener);
}
const rawContent = container.querySelector('.raw-content');
const oldContent = rawContent.textContent;
try {
const editContentZone = container.querySelector<HTMLDivElement>('.edit-content-zone');
const updateUrl = editContentZone.getAttribute('data-update-url');
const context = editContentZone.getAttribute('data-context');
const contentVersion = editContentZone.getAttribute('data-content-version');
const encoder = new TextEncoder();
const buffer = encoder.encode(oldContent);
// Indexes may fall off the ends and return undefined.
if (buffer[position - 1] !== '['.codePointAt(0) ||
buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) ||
buffer[position + 1] !== ']'.codePointAt(0)) {
// Position is probably wrong. Revert and don't allow change.
checkbox.checked = !checkbox.checked;
throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`);
}
buffer.set(encoder.encode(checkboxCharacter), position);
const newContent = new TextDecoder().decode(buffer);
if (newContent === oldContent) {
const requestBody = new FormData();
requestBody.append('ignore_attachments', 'true');
requestBody.append('content', newContent);
requestBody.append('context', context);
requestBody.append('content_version', contentVersion);
const response = await POST(updateUrl, {data: requestBody});
const data = await response.json();
if (response.status === 400) {
showErrorToast(data.errorMessage);
return;
}
editContentZone.setAttribute('data-content-version', data.contentVersion);
rawContent.textContent = newContent;
} catch (err) {
checkbox.checked = !checkbox.checked;
console.error(err);
}
// Prevent further inputs until the request is done. This does not use the
// `disabled` attribute because it causes the border to flash on click.
for (const checkbox of checkboxes) {
checkbox.addEventListener('click', preventListener);
}
try {
const editContentZone = container.querySelector<HTMLDivElement>('.edit-content-zone');
const updateUrl = editContentZone.getAttribute('data-update-url');
const context = editContentZone.getAttribute('data-context');
const contentVersion = editContentZone.getAttribute('data-content-version');
const requestBody = new FormData();
requestBody.append('ignore_attachments', 'true');
requestBody.append('content', newContent);
requestBody.append('context', context);
requestBody.append('content_version', contentVersion);
const response = await POST(updateUrl, {data: requestBody});
const data = await response.json();
if (response.status === 400) {
showErrorToast(data.errorMessage);
return;
}
editContentZone.setAttribute('data-content-version', data.contentVersion);
rawContent.textContent = newContent;
} catch (err) {
checkbox.checked = !checkbox.checked;
console.error(err);
}
// Enable input on checkboxes again
for (const checkbox of checkboxes) {
checkbox.removeEventListener('click', preventListener);
}
});
}
// Enable input on checkboxes again
for (const checkbox of checkboxes) {
checkbox.removeEventListener('click', preventListener);
}
});
// Enable the checkboxes as they are initially disabled by the markdown renderer
for (const checkbox of checkboxes) {

View File

@ -20,6 +20,9 @@ export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(
// It handles the global init functions by a selector, for example:
// > registerGlobalSelectorObserver('.ui.dropdown:not(.custom)', (el) => { initDropdown(el, ...) });
// ATTENTION: For most cases, it's recommended to use registerGlobalInitFunc instead,
// Because this selector-based approach is less efficient and less maintainable.
// But if there are already a lot of elements on many pages, this selector-based approach is more convenient for exiting code.
export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) {
selectorHandlers.push({selector, handler});
// Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.

View File

@ -1,12 +1,10 @@
import {htmlEscape} from 'escape-goat';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export async function initPdfViewer() {
const els = document.querySelectorAll('.pdf-content');
if (!els.length) return;
registerGlobalInitFunc('initPdfViewer', async (el: HTMLInputElement) => {
const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
for (const el of els) {
const src = el.getAttribute('data-src');
const fallbackText = el.getAttribute('data-fallback-button-text');
pdfobject.embed(src, el, {
@ -15,5 +13,5 @@ export async function initPdfViewer() {
`,
});
el.classList.remove('is-loading');
}
});
}