diff --git a/modules/markup/html.go b/modules/markup/html.go index 1c2ae6918d..acd2f18471 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -255,25 +255,28 @@ func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]strin indent := []byte{' ', ' '} _, _ = htmlutil.HTMLPrint(out, "\n") + _, _ = htmlutil.HTMLPrint(out, "\n") currentLevel-- } _, _ = htmlutil.HTMLPrint(out, "\n\n") diff --git a/modules/markup/html_toc_test.go b/modules/markup/html_toc_test.go index e93cfc9346..1493d50e86 100644 --- a/modules/markup/html_toc_test.go +++ b/modules/markup/html_toc_test.go @@ -39,15 +39,15 @@ include_toc: true expected := `
toc
diff --git a/modules/markup/orgmode/orgmode.go b/modules/markup/orgmode/orgmode.go index fd3071645a..2ec3ffce3d 100644 --- a/modules/markup/orgmode/orgmode.go +++ b/modules/markup/orgmode/orgmode.go @@ -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) } diff --git a/modules/markup/render.go b/modules/markup/render.go index c0d44c72fc..94be1b1d76 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -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 diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 7136b87058..9c9b7efdda 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -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() { diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 44bc8543b0..dedff1d563 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -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 } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index eba3ffc36f..f3c7aacbbd 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -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 { diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index d91235e0ea..2d50921fce 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -35,7 +35,10 @@ {{end}}
- {{/* this componment is also controlled by frontend plugin renders */}} + {{if .FileSidebarHTML}} + + {{end}} + {{/* this component is also controlled by frontend plugin renders */}}
{{if .IsRepresentableAsText}} {{svg "octicon-code" 15}} @@ -139,6 +142,11 @@
{{end}}
+ {{if .FileSidebarHTML}} + + {{end}}
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 1dd5301338..9b54c6fddc 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -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 { diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 60bf1f17f9..086a6fcf3b 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -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; + } +} diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index ff9e8cfa26..b654104d9f 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -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('.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 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