0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-01-24 04:15:35 +01:00

feat: add TOC sidebar for README and Markdown files

This commit is contained in:
hamki 2025-12-28 15:17:23 +08:00
parent 1348cb039f
commit 2d7b11e365
3 changed files with 153 additions and 88 deletions

View File

@ -98,59 +98,57 @@
{{if not .IsMarkup}}
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{end}}
<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 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>
{{end}}
</div>
{{end}}
</div>
{{if or .ReadmeTocHTML .FileTocHTML}}
<div class="file-view-toc markup toc-panel-hidden">
{{or .ReadmeTocHTML .FileTocHTML}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{if or .ReadmeTocHTML .FileTocHTML}}
<div class="file-view-toc markup toc-panel-hidden">
{{or .ReadmeTocHTML .FileTocHTML}}
</div>
{{end}}
<div class="code-line-menu tippy-target">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}

View File

@ -76,6 +76,12 @@
.repo-view-content {
flex: 1;
min-width: 0;
transition: margin-right 0.2s ease;
}
/* When TOC is visible, reserve space on the right (only for file view, not home page) */
.repo-view-content.toc-visible {
margin-right: 270px;
}
.language-stats {
@ -106,37 +112,31 @@
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 */
/* TOC as a fixed sidebar panel */
.file-view-toc {
position: fixed;
top: 60px;
right: 0;
width: 280px;
max-height: calc(100vh - 80px);
top: 120px; /* Will be adjusted by JS to align with file content */
right: 0.5rem;
width: 260px;
max-height: calc(100vh - 140px);
overflow-y: auto;
padding: 1rem;
background: var(--color-body);
padding: 0.75rem;
background: var(--color-box-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;
border-radius: var(--border-radius);
z-index: 50;
opacity: 0; /* Hidden until JS positions it */
transition: opacity 0.15s ease;
}
/* Show TOC after JS has positioned it */
.file-view-toc.toc-positioned {
opacity: 1;
}
/* Hidden state - using custom class to avoid Tailwind conflicts */
.file-view-toc.toc-panel-hidden {
transform: translateX(100%);
display: none;
}
.file-view-toc details {
@ -180,17 +180,20 @@
/* 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) {
@media (max-width: 1400px) {
.file-view-toc {
width: 100%;
max-width: 300px;
display: none !important;
}
#toggle-toc-btn {
display: none;
}
/* Don't reserve space for TOC on small screens */
.repo-view-content.toc-visible {
margin-right: 0;
}
}

View File

@ -59,35 +59,99 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
}
}
function updateTocPosition(elFileView: HTMLElement, tocSidebar: HTMLElement): void {
const fileHeader = elFileView.querySelector('.file-header');
if (!fileHeader) return;
const headerRect = fileHeader.getBoundingClientRect();
// Align TOC top with the file header top, with a minimum of 12px from viewport top
const topPos = Math.max(12, headerRect.top);
tocSidebar.style.top = `${topPos}px`;
// Position TOC right next to the content (works for both file view and home page)
const segment = elFileView.querySelector('.ui.segment');
if (segment) {
const segmentRect = segment.getBoundingClientRect();
const leftPos = segmentRect.right + 8; // 8px gap from content
tocSidebar.style.left = `${leftPos}px`;
tocSidebar.style.right = 'auto';
}
// Mark as positioned to show the TOC (prevents flicker)
tocSidebar.classList.add('toc-positioned');
}
function initTocToggle(elFileView: HTMLElement): void {
const toggleBtn = elFileView.querySelector('#toggle-toc-btn');
const tocSidebar = elFileView.querySelector('.file-view-toc');
const tocSidebar = elFileView.querySelector<HTMLElement>('.file-view-toc');
if (!toggleBtn || !tocSidebar) return;
// Check if we're in file view (not home page) - only file view needs margin adjustment
const repoViewContent = elFileView.closest('.repo-view-content');
const isFileView = Boolean(repoViewContent);
// Helper to update position
const updatePosition = () => {
if (!tocSidebar.classList.contains('toc-panel-hidden')) {
updateTocPosition(elFileView, tocSidebar);
}
};
// Helper to show TOC with proper positioning
const showToc = () => {
toggleBtn.classList.add('active');
// Wait for margin to take effect before showing and positioning TOC
const showAfterLayout = () => {
tocSidebar.classList.remove('toc-panel-hidden');
requestAnimationFrame(() => {
updateTocPosition(elFileView, tocSidebar);
});
};
// For file view, first add margin, wait for layout, then show TOC
if (isFileView) {
repoViewContent.classList.add('toc-visible');
// Wait for CSS transition to complete (200ms) before calculating position
setTimeout(showAfterLayout, 220);
} else {
// For home page (README), no margin needed, show with small delay
setTimeout(showAfterLayout, 10);
}
};
// Helper to hide TOC
const hideToc = () => {
tocSidebar.classList.add('toc-panel-hidden');
tocSidebar.classList.remove('toc-positioned');
toggleBtn.classList.remove('active');
if (isFileView) {
repoViewContent.classList.remove('toc-visible');
}
};
// 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');
showToc();
} else {
tocSidebar.classList.add('toc-panel-hidden');
toggleBtn.classList.remove('active');
hideToc();
}
// Update TOC position on resize/scroll to keep aligned with file content
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition);
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');
hideToc();
localStorage.setItem('file-view-toc-visible', 'false');
} else {
// Show TOC
tocSidebar.classList.remove('toc-panel-hidden');
toggleBtn.classList.add('active');
showToc();
localStorage.setItem('file-view-toc-visible', 'true');
}
});