0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-01-23 21:49:21 +01:00

feat: add TOC sidebar for README and Markdown files

- Add floating TOC panel that displays table of contents for README and markdown files
- Implement toggle button to show/hide TOC panel
- Add localStorage persistence for TOC panel visibility state
- Support both README in repo root and individual markdown file views
- Add responsive design for mobile screens
This commit is contained in:
hamki 2025-12-26 23:16:00 +08:00
parent eddf875992
commit 19fa68afd1
6 changed files with 199 additions and 43 deletions

View File

@ -35,6 +35,7 @@ import (
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
@ -179,6 +180,18 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i
return escaped, output, err
}
func renderSidebarTocHTML(rctx *markup.RenderContext) template.HTML {
if rctx.SidebarTocNode == nil {
return ""
}
sb := &strings.Builder{}
if err := markdown.SpecializedMarkdown(rctx).Renderer().Render(sb, nil, rctx.SidebarTocNode); err != nil {
log.Error("Failed to render sidebar TOC: %v", err)
return ""
}
return templates.SanitizeHTML(sb.String())
}
func checkHomeCodeViewable(ctx *context.Context) {
if ctx.Repo.HasUnits() {
if ctx.Repo.Repository.IsBeingCreated() {

View File

@ -92,6 +92,8 @@ func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedTy
ctx.ServerError("Render", err)
return true
}
ctx.Data["FileTocHTML"] = renderSidebarTocHTML(rctx)
return true
}

View File

@ -206,6 +206,8 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err)
delete(ctx.Data, "IsMarkup")
}
ctx.Data["ReadmeTocHTML"] = renderSidebarTocHTML(rctx)
}
if ctx.Data["IsMarkup"] != true {

View File

@ -35,6 +35,11 @@
{{end}}
</div>
<div class="file-header-right file-actions flex-text-block tw-flex-wrap">
{{if or .ReadmeTocHTML .FileTocHTML}}
<button class="btn-octicon" id="toggle-toc-btn" data-tooltip-content="{{ctx.Locale.Tr "toc"}}">
{{svg "octicon-list-unordered" 16}}
</button>
{{end}}
{{/* this componment is also controlled by frontend plugin renders */}}
<div class="ui compact icon buttons file-view-toggle-buttons {{Iif .HasSourceRenderedToggle "" "tw-hidden"}}">
{{if .IsRepresentableAsText}}
@ -93,50 +98,57 @@
{{if not .IsMarkup}}
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{end}}
<div class="file-view {{if .IsMarkup}}markup {{.MarkupType}}{{else if .IsPlainText}}plain-text{{else if .IsDisplayingSource}}code-view{{end}}">
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
{{else if not .FileSize}}
{{template "shared/fileisempty"}}
{{else if .IsMarkup}}
{{.FileContent}}
{{else if .IsPlainText}}
<pre>{{if .FileContent}}{{.FileContent}}{{end}}</pre>
{{else if .FileContent}}
<table>
<tbody>
{{range $idx, $code := .FileContent}}
{{$line := Eval $idx "+" 1}}
<tr>
<td class="lines-num"><span id="L{{$line}}" data-line-number="{{$line}}"></span></td>
{{if $.EscapeStatus.Escaped}}
<td class="lines-escape">{{if (index $.LineEscapeStatus $idx).Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{if (index $.LineEscapeStatus $idx).HasInvisible}}{{ctx.Locale.Tr "repo.invisible_runes_line"}} {{end}}{{if (index $.LineEscapeStatus $idx).HasAmbiguous}}{{ctx.Locale.Tr "repo.ambiguous_runes_line"}}{{end}}"></button>{{end}}</td>
{{end}}
<td rel="L{{$line}}" class="lines-code chroma"><code class="code-inner">{{$code}}</code></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="view-raw">
{{if .IsImageFile}}
<img alt="{{$.RawFileLink}}" src="{{$.RawFileLink}}">
{{else if .IsVideoFile}}
<video controls src="{{$.RawFileLink}}">
<strong>{{ctx.Locale.Tr "repo.video_not_supported_in_browser"}}</strong>
</video>
{{else if .IsAudioFile}}
<audio controls src="{{$.RawFileLink}}">
<strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong>
</audio>
{{else}}
<div class="file-view-render-container">
<div class="file-view-raw-prompt tw-p-4">
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
<div class="file-view-container">
<div class="file-view {{if .IsMarkup}}markup {{.MarkupType}}{{else if .IsPlainText}}plain-text{{else if .IsDisplayingSource}}code-view{{end}}">
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
{{else if not .FileSize}}
{{template "shared/fileisempty"}}
{{else if .IsMarkup}}
{{.FileContent}}
{{else if .IsPlainText}}
<pre>{{if .FileContent}}{{.FileContent}}{{end}}</pre>
{{else if .FileContent}}
<table>
<tbody>
{{range $idx, $code := .FileContent}}
{{$line := Eval $idx "+" 1}}
<tr>
<td class="lines-num"><span id="L{{$line}}" data-line-number="{{$line}}"></span></td>
{{if $.EscapeStatus.Escaped}}
<td class="lines-escape">{{if (index $.LineEscapeStatus $idx).Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{if (index $.LineEscapeStatus $idx).HasInvisible}}{{ctx.Locale.Tr "repo.invisible_runes_line"}} {{end}}{{if (index $.LineEscapeStatus $idx).HasAmbiguous}}{{ctx.Locale.Tr "repo.ambiguous_runes_line"}}{{end}}"></button>{{end}}</td>
{{end}}
<td rel="L{{$line}}" class="lines-code chroma"><code class="code-inner">{{$code}}</code></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="view-raw">
{{if .IsImageFile}}
<img alt="{{$.RawFileLink}}" src="{{$.RawFileLink}}">
{{else if .IsVideoFile}}
<video controls src="{{$.RawFileLink}}">
<strong>{{ctx.Locale.Tr "repo.video_not_supported_in_browser"}}</strong>
</video>
{{else if .IsAudioFile}}
<audio controls src="{{$.RawFileLink}}">
<strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong>
</audio>
{{else}}
<div class="file-view-render-container">
<div class="file-view-raw-prompt tw-p-4">
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
</div>
</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{if or .ReadmeTocHTML .FileTocHTML}}
<div class="file-view-toc markup toc-panel-hidden">
{{or .ReadmeTocHTML .FileTocHTML}}
</div>
{{end}}
</div>

View File

@ -105,3 +105,92 @@
padding: 0 0.5em; /* make the UI look better for narrow (mobile) view */
text-decoration: none;
}
/* File view with TOC sidebar */
.file-view-container {
position: relative;
}
.file-view-container .file-view {
width: 100%;
}
/* TOC as a floating sidebar panel */
.file-view-toc {
position: fixed;
top: 60px;
right: 0;
width: 280px;
max-height: calc(100vh - 80px);
overflow-y: auto;
padding: 1rem;
background: var(--color-body);
border: 1px solid var(--color-secondary);
border-right: none;
border-radius: var(--border-radius) 0 0 var(--border-radius);
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15);
z-index: 100;
transform: translateX(0);
transition: transform 0.25s ease-in-out;
}
/* Hidden state - using custom class to avoid Tailwind conflicts */
.file-view-toc.toc-panel-hidden {
transform: translateX(100%);
}
.file-view-toc details {
font-size: 13px;
}
.file-view-toc summary {
font-weight: 600;
cursor: pointer;
padding: 8px 0;
color: var(--color-text);
border-bottom: 1px solid var(--color-secondary);
margin-bottom: 8px;
}
.file-view-toc ul {
margin: 0;
list-style: none;
padding: 5px 0 5px 1em;
}
.file-view-toc ul ul {
border-left: 1px dashed var(--color-secondary);
}
.file-view-toc a {
display: block;
padding: 6px 10px;
color: var(--color-text);
text-decoration: none;
font-size: 13px;
line-height: 1.4;
border-radius: var(--border-radius);
}
.file-view-toc a:hover {
color: var(--color-primary);
background: var(--color-hover);
}
/* TOC toggle button active state - when TOC is visible */
#toggle-toc-btn.active {
color: var(--color-primary);
background: var(--color-hover);
}
/* Hide TOC sidebar on small screens */
@media (max-width: 768px) {
.file-view-toc {
width: 100%;
max-width: 300px;
}
#toggle-toc-btn {
display: none;
}
}

View File

@ -59,9 +59,47 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
}
}
function initTocToggle(elFileView: HTMLElement): void {
const toggleBtn = elFileView.querySelector('#toggle-toc-btn');
const tocSidebar = elFileView.querySelector('.file-view-toc');
if (!toggleBtn || !tocSidebar) return;
// Restore saved state from localStorage (default to hidden)
const savedState = localStorage.getItem('file-view-toc-visible');
const isVisible = savedState === 'true'; // default to hidden
// Apply initial state
if (isVisible) {
tocSidebar.classList.remove('toc-panel-hidden');
toggleBtn.classList.add('active');
} else {
tocSidebar.classList.add('toc-panel-hidden');
toggleBtn.classList.remove('active');
}
toggleBtn.addEventListener('click', () => {
const isCurrentlyVisible = !tocSidebar.classList.contains('toc-panel-hidden');
if (isCurrentlyVisible) {
// Hide TOC
tocSidebar.classList.add('toc-panel-hidden');
toggleBtn.classList.remove('active');
localStorage.setItem('file-view-toc-visible', 'false');
} else {
// Show TOC
tocSidebar.classList.remove('toc-panel-hidden');
toggleBtn.classList.add('active');
localStorage.setItem('file-view-toc-visible', 'true');
}
});
}
export function initRepoFileView(): void {
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
initPluginsOnce();
// Initialize TOC toggle functionality
initTocToggle(elFileView);
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