mirror of
https://github.com/go-gitea/gitea.git
synced 2026-01-22 02:27:11 +01:00
Merge c5a65b9d455c6cbec2b2ff20a8fe30934221388c into fafd1db19e3aae98a92fb3a5673f34e389a26e4b
This commit is contained in:
commit
2e4259f10f
@ -86,8 +86,10 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
|
||||
tocNode := createTOCNode(tocList, rc.Lang, nil)
|
||||
node.InsertBefore(node, firstChild, tocNode)
|
||||
} else {
|
||||
tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"})
|
||||
ctx.SidebarTocNode = tocNode
|
||||
ctx.SidebarTocHeaders = make([]markup.Header, len(tocList))
|
||||
for i, h := range tocList {
|
||||
ctx.SidebarTocHeaders[i] = markup.Header{Level: h.Level, Text: h.Text, ID: h.ID}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -96,7 +96,16 @@ 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)
|
||||
// Parse the document first to extract outline for TOC
|
||||
doc := org.New().Silent().Parse(input, "")
|
||||
if doc.Error != nil {
|
||||
return fmt.Errorf("orgmode.Parse failed: %w", doc.Error)
|
||||
}
|
||||
|
||||
// Extract headers from the document outline for sidebar TOC
|
||||
ctx.SidebarTocHeaders = extractHeadersFromOutline(doc.Outline)
|
||||
|
||||
res, err := doc.Write(w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orgmode.Render failed: %w", err)
|
||||
}
|
||||
@ -104,6 +113,37 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
|
||||
return err
|
||||
}
|
||||
|
||||
// extractHeadersFromOutline recursively extracts headers from org document outline
|
||||
func extractHeadersFromOutline(outline org.Outline) []markup.Header {
|
||||
var headers []markup.Header
|
||||
collectHeaders(outline.Section, &headers)
|
||||
return headers
|
||||
}
|
||||
|
||||
// collectHeaders recursively collects headers from sections
|
||||
func collectHeaders(section *org.Section, headers *[]markup.Header) {
|
||||
if section == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Process current section's headline
|
||||
if section.Headline != nil {
|
||||
h := section.Headline
|
||||
// Convert headline title nodes to plain text
|
||||
titleText := org.String(h.Title...)
|
||||
*headers = append(*headers, markup.Header{
|
||||
Level: h.Lvl,
|
||||
Text: titleText,
|
||||
ID: h.ID(),
|
||||
})
|
||||
}
|
||||
|
||||
// Process child sections
|
||||
for _, child := range section.Children {
|
||||
collectHeaders(child, headers)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderString renders orgmode string to HTML string
|
||||
func RenderString(ctx *markup.RenderContext, content string) (string, error) {
|
||||
var buf strings.Builder
|
||||
|
||||
@ -18,7 +18,6 @@ import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@ -36,6 +35,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
|
||||
|
||||
@ -67,7 +73,7 @@ type RenderContext struct {
|
||||
// the context might be used by the "render" function, but it might also be used by "postProcess" function
|
||||
usedByRender bool
|
||||
|
||||
SidebarTocNode ast.Node
|
||||
SidebarTocHeaders []Header // Headers for generating sidebar TOC
|
||||
|
||||
RenderHelper RenderHelper
|
||||
RenderOptions RenderOptions
|
||||
|
||||
77
modules/markup/sidebar_toc.go
Normal file
77
modules/markup/sidebar_toc.go
Normal file
@ -0,0 +1,77 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"html"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
)
|
||||
|
||||
// RenderSidebarTocHTML renders a list of headers into HTML for sidebar TOC display.
|
||||
// It generates a <details> element with nested <ul> lists representing the header hierarchy.
|
||||
func RenderSidebarTocHTML(headers []Header, lang string) template.HTML {
|
||||
if len(headers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Start with <details open>
|
||||
sb.WriteString(`<details open>`)
|
||||
sb.WriteString(`<summary>`)
|
||||
sb.WriteString(html.EscapeString(translation.NewLocale(lang).TrString("toc")))
|
||||
sb.WriteString(`</summary>`)
|
||||
|
||||
// Find the minimum level to start with
|
||||
minLevel := 6
|
||||
for _, header := range headers {
|
||||
if header.Level < minLevel {
|
||||
minLevel = header.Level
|
||||
}
|
||||
}
|
||||
|
||||
// Build nested list structure
|
||||
currentLevel := minLevel
|
||||
sb.WriteString(`<ul>`)
|
||||
openLists := 1
|
||||
|
||||
for _, header := range headers {
|
||||
// Close lists if we need to go up levels
|
||||
for currentLevel > header.Level {
|
||||
sb.WriteString(`</ul>`)
|
||||
openLists--
|
||||
currentLevel--
|
||||
}
|
||||
|
||||
// Open new lists if we need to go down levels
|
||||
for currentLevel < header.Level {
|
||||
sb.WriteString(`<ul>`)
|
||||
openLists++
|
||||
currentLevel++
|
||||
}
|
||||
|
||||
// Write the list item with link
|
||||
sb.WriteString(`<li>`)
|
||||
sb.WriteString(`<a href="#`)
|
||||
sb.WriteString(url.QueryEscape(header.ID))
|
||||
sb.WriteString(`">`)
|
||||
sb.WriteString(html.EscapeString(header.Text))
|
||||
sb.WriteString(`</a>`)
|
||||
sb.WriteString(`</li>`)
|
||||
}
|
||||
|
||||
// Close all remaining open lists
|
||||
for openLists > 0 {
|
||||
sb.WriteString(`</ul>`)
|
||||
openLists--
|
||||
}
|
||||
|
||||
sb.WriteString(`</details>`)
|
||||
|
||||
return template.HTML(sb.String())
|
||||
}
|
||||
@ -179,6 +179,13 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i
|
||||
return escaped, output, err
|
||||
}
|
||||
|
||||
func renderSidebarTocHTML(rctx *markup.RenderContext, lang string) template.HTML {
|
||||
if len(rctx.SidebarTocHeaders) > 0 {
|
||||
return markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, lang)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkHomeCodeViewable(ctx *context.Context) {
|
||||
if ctx.Repo.HasUnits() {
|
||||
if ctx.Repo.Repository.IsBeingCreated() {
|
||||
|
||||
@ -92,6 +92,8 @@ func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedTy
|
||||
ctx.ServerError("Render", err)
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx, ctx.Locale.Language())
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@ -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["FileSidebarHTML"] = renderSidebarTocHTML(rctx, ctx.Locale.Language())
|
||||
}
|
||||
|
||||
if ctx.Data["IsMarkup"] != true {
|
||||
|
||||
@ -277,12 +277,9 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if rctx.SidebarTocNode != nil {
|
||||
sb := strings.Builder{}
|
||||
if err = markdown.SpecializedMarkdown(rctx).Renderer().Render(&sb, nil, rctx.SidebarTocNode); err != nil {
|
||||
log.Error("Failed to render wiki sidebar TOC: %v", err)
|
||||
}
|
||||
ctx.Data["WikiSidebarTocHTML"] = templates.SanitizeHTML(sb.String())
|
||||
// Render sidebar TOC
|
||||
if len(rctx.SidebarTocHeaders) > 0 {
|
||||
ctx.Data["WikiSidebarTocHTML"] = markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, ctx.Locale.Language())
|
||||
}
|
||||
|
||||
if !isSideBar {
|
||||
|
||||
@ -35,6 +35,9 @@
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="file-header-right file-actions flex-text-block tw-flex-wrap">
|
||||
{{if .FileSidebarHTML}}
|
||||
<button class="btn-octicon" id="toggle-sidebar-btn" data-tooltip-content="{{ctx.Locale.Tr "toc"}}">{{svg "octicon-list-unordered" 15}}</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}}
|
||||
@ -139,6 +142,11 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .FileSidebarHTML}}
|
||||
<div class="file-view-sidebar markup sidebar-panel-hidden">
|
||||
{{.FileSidebarHTML}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="code-line-menu tippy-target">
|
||||
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
|
||||
|
||||
@ -167,6 +167,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 {
|
||||
|
||||
@ -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,100 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,9 +59,145 @@ 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);
|
||||
const cssMaxHeight = window.innerHeight - 140; // Match CSS calc(100vh - 140px)
|
||||
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('.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 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 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 from localStorage (default to hidden)
|
||||
const savedState = localStorage.getItem('file-view-sidebar-visible');
|
||||
const isVisible = savedState === 'true'; // default to hidden
|
||||
|
||||
// Apply initial state
|
||||
if (isVisible) {
|
||||
showSidebar();
|
||||
} else {
|
||||
hideSidebar();
|
||||
}
|
||||
|
||||
// Update sidebar position on resize/scroll to keep aligned with file content
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updatePosition();
|
||||
});
|
||||
resizeObserver.observe(document.body);
|
||||
|
||||
// Update position using IntersectionObserver instead of scroll event
|
||||
// This provides better performance and avoids scroll event issues
|
||||
const fileHeader = elFileView.querySelector('.file-header');
|
||||
const segment = elFileView.querySelector('.ui.bottom.segment');
|
||||
if (fileHeader && segment) {
|
||||
// Use many thresholds to get fine-grained position updates during scroll
|
||||
const thresholds = Array.from({length: 101}, (_, i) => i / 100);
|
||||
const positionObserver = new IntersectionObserver(() => {
|
||||
updatePosition();
|
||||
}, {threshold: thresholds});
|
||||
positionObserver.observe(segment);
|
||||
positionObserver.observe(fileHeader);
|
||||
}
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden');
|
||||
if (isCurrentlyVisible) {
|
||||
hideSidebar();
|
||||
localStorage.setItem('file-view-sidebar-visible', 'false');
|
||||
} else {
|
||||
showSidebar();
|
||||
localStorage.setItem('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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user