0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-06 05:15:30 +02:00

Merge 81f930e6715f94b3687682337f7b4edf750ea59b into 7b17234945ff3f7c6f09c54d9c4ffc93dc137212

This commit is contained in:
hamkido 2026-04-02 20:58:22 -07:00 committed by GitHub
commit 948ac7424c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 330 additions and 11 deletions

View File

@ -255,25 +255,28 @@ func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]strin
indent := []byte{' ', ' '}
_, _ = htmlutil.HTMLPrint(out, "<ul>\n")
for _, header := range ctx.TocHeadingItems {
// Go deeper: open nested <ul> elements (wrapped in <li> for valid HTML)
for currentLevel < header.HeadingLevel {
_, _ = out.Write(indent)
_, _ = htmlutil.HTMLPrint(out, "<ul>\n")
_, _ = htmlutil.HTMLPrint(out, "<li><ul>\n")
indent = append(indent, ' ', ' ')
currentLevel++
}
// Go shallower: close nested </ul></li> elements
for currentLevel > header.HeadingLevel {
indent = indent[:len(indent)-2]
_, _ = out.Write(indent)
_, _ = htmlutil.HTMLPrint(out, "</ul>\n")
_, _ = htmlutil.HTMLPrint(out, "</ul></li>\n")
currentLevel--
}
_, _ = out.Write(indent)
_, _ = htmlutil.HTMLPrintf(out, "<li><a href=\"#%s\">%s</a></li>\n", header.AnchorID, header.InnerText)
}
// Close any remaining nested levels
for currentLevel > baseLevel {
indent = indent[:len(indent)-2]
_, _ = out.Write(indent)
_, _ = htmlutil.HTMLPrint(out, "</ul>\n")
_, _ = htmlutil.HTMLPrint(out, "</ul></li>\n")
currentLevel--
}
_, _ = htmlutil.HTMLPrint(out, "</ul>\n</details>\n")

View File

@ -39,15 +39,15 @@ include_toc: true
expected := `<details><summary>toc</summary>
<ul>
<li><a href="#user-content-tag-link-and-bold" rel="nofollow">tag link and Bold</a></li>
<ul>
<li><ul>
<li><a href="#user-content-code-block-a" rel="nofollow">code block &lt;a&gt;</a></li>
<ul>
<ul>
<li><ul>
<li><ul>
<li><a href="#user-content-markdown-bold" rel="nofollow">markdown bold</a></li>
</ul>
</ul>
</ul></li>
</ul></li>
<li><a href="#user-content-last" rel="nofollow">last</a></li>
</ul>
</ul></li>
</ul>
</details>

View File

@ -70,7 +70,18 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
htmlWriter.ExtendingWriter = w
res, err := org.New().Silent().Parse(input, "").Write(w)
doc := org.New().Silent().Parse(input, "")
if doc.Error != nil {
return fmt.Errorf("orgmode.Parse failed: %w", doc.Error)
}
// Enable TOC extraction in post-process step for orgmode files
// The actual TOC items will be extracted from HTML headings during post-processing
if ctx.RenderOptions.EnableHeadingIDGeneration {
ctx.TocShowInSection = markup.TocShowInSidebar
}
res, err := doc.Write(w)
if err != nil {
return fmt.Errorf("orgmode.Render failed: %w", err)
}

View File

@ -38,6 +38,13 @@ var RenderBehaviorForTesting struct {
DisableAdditionalAttributes bool
}
// Header holds the data about a header for generating TOC
type Header struct {
Level int
Text string
ID string
}
type RenderOptions struct {
UseAbsoluteLink bool

View File

@ -174,6 +174,15 @@ func markupRenderToHTML(ctx *context.Context, renderCtx *markup.RenderContext, r
return escaped, output, err
}
func renderSidebarTocHTML(rctx *markup.RenderContext) template.HTML {
if rctx.TocShowInSection == markup.TocShowInSidebar && len(rctx.TocHeadingItems) > 0 {
sb := strings.Builder{}
markup.RenderTocHeadingItems(rctx, map[string]string{"open": ""}, &sb)
return template.HTML(sb.String())
}
return ""
}
func checkHomeCodeViewable(ctx *context.Context) {
if ctx.Repo.HasUnits() {
if ctx.Repo.Repository.IsBeingCreated() {

View File

@ -86,6 +86,8 @@ func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Re
ctx.ServerError("Render", err)
return true
}
ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx)
return true
}

View File

@ -202,6 +202,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["FileSidebarHTML"] = renderSidebarTocHTML(rctx)
}
if ctx.Data["IsMarkup"] != true {

View File

@ -35,7 +35,10 @@
{{end}}
</div>
<div class="file-header-right file-actions flex-text-block tw-flex-wrap">
{{/* this componment is also controlled by frontend plugin renders */}}
{{if .FileSidebarHTML}}
<button class="btn-octicon" id="toggle-sidebar-btn" data-tooltip-content="{{ctx.Locale.Tr "toc"}}">{{svg "octicon-list-unordered"}}</button>
{{end}}
{{/* this component is also controlled by frontend plugin renders */}}
<div class="ui compact icon buttons file-view-toggle-buttons {{Iif .HasSourceRenderedToggle "" "tw-hidden"}}">
{{if .IsRepresentableAsText}}
<a href="?display=source" class="ui mini basic button file-view-toggle-source {{if .IsDisplayingSource}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_source"}}">{{svg "octicon-code" 15}}</a>
@ -139,6 +142,11 @@
</div>
{{end}}
</div>
{{if .FileSidebarHTML}}
<div class="file-view-sidebar markup sidebar-panel-hidden" role="navigation" aria-label="{{ctx.Locale.Tr "toc"}}">
{{.FileSidebarHTML}}
</div>
{{end}}
<div class="code-line-menu tippy-target">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}

View File

@ -169,6 +169,9 @@ td .commit-summary {
padding: 8px;
vertical-align: middle;
color: var(--color-text);
background: none;
border: none;
cursor: pointer;
}
.non-diff-file-content .header .file-actions .btn-octicon:hover {

View File

@ -76,6 +76,12 @@
.repo-view-content {
flex: 1;
min-width: 0;
transition: margin-right 0.2s ease;
}
/* When sidebar is visible, reserve space on the right (only for file view, not home page) */
.repo-view-content.sidebar-visible {
margin-right: 270px;
}
.language-stats {
@ -105,3 +111,122 @@
padding: 0 0.5em; /* make the UI look better for narrow (mobile) view */
text-decoration: none;
}
/* File view sidebar panel (e.g., TOC for markdown files) */
.file-view-sidebar {
position: fixed;
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: 0.75rem;
background: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
z-index: 50;
opacity: 0; /* Hidden until JS positions it */
transition: opacity 0.15s ease;
}
/* Show sidebar after JS has positioned it */
.file-view-sidebar.sidebar-positioned {
opacity: 1;
}
/* Hidden state - using custom class to avoid Tailwind conflicts */
.file-view-sidebar.sidebar-panel-hidden {
display: none;
}
.file-view-sidebar details {
font-size: 13px;
}
.file-view-sidebar summary {
font-weight: var(--font-weight-semibold);
cursor: pointer;
padding: 8px 0;
color: var(--color-text);
border-bottom: 1px solid var(--color-secondary);
margin-bottom: 8px;
}
.file-view-sidebar ul {
margin: 0;
list-style: none;
padding: 5px 0 5px 1em;
}
.file-view-sidebar ul ul {
border-left: 1px dashed var(--color-secondary);
}
.file-view-sidebar 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-sidebar a:hover {
color: var(--color-primary);
background: var(--color-hover);
}
/* Sidebar toggle button styling for file view (not readme) - add border to match other buttons */
.file-header-right #toggle-sidebar-btn {
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
}
.file-header-right #toggle-sidebar-btn:hover {
border-color: var(--color-primary);
}
/* README view: TOC button should not have border */
#readme .file-header-right #toggle-sidebar-btn {
border: none;
}
#readme .file-header-right #toggle-sidebar-btn:hover {
border: none;
}
/* Sidebar toggle button active state - when sidebar is visible */
#toggle-sidebar-btn.active {
color: var(--color-primary);
border-color: var(--color-primary);
}
/* Hide sidebar on small screens (phones) */
@media (max-width: 768px) {
.file-view-sidebar {
display: none !important;
}
#toggle-sidebar-btn {
display: none;
}
/* Don't reserve space for sidebar on small screens */
.repo-view-content.sidebar-visible {
margin-right: 0;
}
}
/* Adjust sidebar layout on tablet-sized screens to prevent layout issues */
@media (min-width: 769px) and (max-width: 1024px) {
/* Slightly reduce sidebar width in the grid layout */
.repo-grid-filelist-sidebar {
grid-template-columns: auto 220px;
}
/* Match reserved content margin to the narrower sidebar */
.repo-view-content.sidebar-visible {
margin-right: 220px;
}
}

View File

@ -2,6 +2,7 @@ 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 {localUserSettings} from '../modules/user-settings.ts';
import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
@ -59,9 +60,157 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
}
}
function updateSidebarPosition(elFileView: HTMLElement, sidebar: HTMLElement): void {
const fileHeader = elFileView.querySelector('.file-header');
const segment = elFileView.querySelector('.ui.bottom.segment');
if (!fileHeader || !segment) return;
const headerRect = fileHeader.getBoundingClientRect();
const segmentRect = segment.getBoundingClientRect();
// Calculate top position:
// - When file header is visible: align with file header top
// - When file header scrolls above viewport: stick to top (12px)
// - Ensure sidebar top doesn't go above segment top (when scrolling up)
const minTop = 12;
let topPos = Math.max(minTop, headerRect.top);
topPos = Math.max(topPos, segmentRect.top); // Don't go above segment top
// Dynamically calculate max-height so sidebar doesn't extend below segment bottom
const availableHeight = Math.max(0, segmentRect.bottom - topPos);
// 140px accounts for fixed layout chrome (header, spacing, etc.) and must match CSS: calc(100vh - 140px)
const cssMaxHeightOffset = 140;
const cssMaxHeight = window.innerHeight - cssMaxHeightOffset;
const maxHeight = Math.min(availableHeight, cssMaxHeight);
// Hide sidebar if available height is too small
if (maxHeight < 50) {
sidebar.style.opacity = '0';
return;
}
sidebar.style.maxHeight = `${maxHeight}px`;
sidebar.style.opacity = '';
sidebar.style.top = `${topPos}px`;
// Position sidebar right next to the content
const leftPos = segmentRect.right + 8; // 8px gap from content
sidebar.style.left = `${leftPos}px`;
sidebar.style.right = 'auto';
// Mark as positioned to show the sidebar (prevents flicker)
sidebar.classList.add('sidebar-positioned');
}
function initSidebarToggle(elFileView: HTMLElement): void {
const toggleBtn = elFileView.querySelector('#toggle-sidebar-btn');
const sidebar = elFileView.querySelector<HTMLElement>('.file-view-sidebar');
if (!toggleBtn || !sidebar) return;
// Check if we're in file view (not home page) - only file view needs margin adjustment
const repoViewContent = elFileView.closest<HTMLElement>('.repo-view-content');
const isFileView = Boolean(repoViewContent);
// Helper to update position
const updatePosition = () => {
if (!sidebar.classList.contains('sidebar-panel-hidden')) {
updateSidebarPosition(elFileView, sidebar);
}
};
// Helper to show sidebar with proper positioning
const showSidebar = () => {
toggleBtn.classList.add('active');
// Wait for margin to take effect before showing and positioning sidebar
const showAfterLayout = () => {
sidebar.classList.remove('sidebar-panel-hidden');
requestAnimationFrame(() => {
updateSidebarPosition(elFileView, sidebar);
});
};
// For file view, first add margin, wait for layout, then show sidebar
if (isFileView && repoViewContent) {
repoViewContent.classList.add('sidebar-visible');
// Wait for CSS transition to actually complete before calculating position
const onTransitionEnd = (event: TransitionEvent) => {
if (event.target !== repoViewContent) return;
repoViewContent.removeEventListener('transitionend', onTransitionEnd);
showAfterLayout();
};
repoViewContent.addEventListener('transitionend', onTransitionEnd);
} else {
// For home page (README), no margin needed, show with small delay
setTimeout(showAfterLayout, 10);
}
};
// Helper to hide sidebar
const hideSidebar = () => {
sidebar.classList.add('sidebar-panel-hidden');
sidebar.classList.remove('sidebar-positioned');
toggleBtn.classList.remove('active');
if (isFileView && repoViewContent) {
repoViewContent.classList.remove('sidebar-visible');
}
};
// Restore saved state (default to hidden)
const isVisible = localUserSettings.getBoolean('file-view-sidebar-visible');
// Apply initial state
if (isVisible) {
showSidebar();
} else {
hideSidebar();
}
// Update sidebar position on resize to keep aligned with file content
// Only observe the segment element to avoid unnecessary updates from unrelated page changes
const fileHeader = elFileView.querySelector('.file-header');
const segment = elFileView.querySelector('.ui.bottom.segment');
const resizeObserver = new ResizeObserver(() => {
updatePosition();
});
if (segment) {
resizeObserver.observe(segment);
}
// Update position using IntersectionObserver for scroll tracking
// Use 101 thresholds (0%, 1%, 2%, ..., 100%) for fine-grained position updates
if (fileHeader && segment) {
const thresholds = Array.from({length: 101}, (_, i) => i / 100);
const positionObserver = new IntersectionObserver((entries) => {
// Only update if any entry is intersecting (visible)
if (entries.some((e) => e.isIntersecting)) {
updatePosition();
}
}, {threshold: thresholds});
positionObserver.observe(segment);
positionObserver.observe(fileHeader);
}
toggleBtn.addEventListener('click', () => {
const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden');
if (isCurrentlyVisible) {
hideSidebar();
localUserSettings.setBoolean('file-view-sidebar-visible', false);
} else {
showSidebar();
localUserSettings.setBoolean('file-view-sidebar-visible', true);
}
});
}
export function initRepoFileView(): void {
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
initPluginsOnce();
// Initialize sidebar toggle functionality (e.g., TOC for markdown files)
initSidebarToggle(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