0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-14 09:20:55 +02:00
gitea/web_src/js/features/file-view.ts
hamki 8a6a348771
perf: optimize ResizeObserver to only observe necessary element
Change ResizeObserver from observing document.body (too broad) to only
observing the segment element. This reduces unnecessary position update
callbacks when unrelated parts of the page resize.
2026-01-26 17:28:20 +08:00

231 lines
8.8 KiB
TypeScript

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 {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
const plugins: FileRenderPlugin[] = [];
function initPluginsOnce(): void {
if (plugins.length) return;
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
}
function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
}
function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons')!;
showElem(toggleButtons);
const displayingRendered = Boolean(renderContainer);
toggleElemClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist
toggleElemClass(toggleButtons.querySelector('.file-view-toggle-rendered')!, 'active', displayingRendered);
// TODO: if there is only one button, hide it?
}
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
let rendered = false, errorMsg = '';
try {
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
if (plugin) {
container.classList.add('is-loading');
container.setAttribute('data-render-name', plugin.name); // not used yet
await plugin.render(container, rawFileLink);
rendered = true;
}
} catch (e) {
errorMsg = `${e}`;
} finally {
container.classList.remove('is-loading');
}
if (rendered) {
elViewRawPrompt.remove();
return;
}
// remove all children from the container, and only show the raw file link
container.replaceChildren(elViewRawPrompt);
if (errorMsg) {
const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
}
}
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('.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 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 to keep aligned with file content
// Only observe the segment element to avoid unnecessary updates from unrelated page changes
const resizeObserver = new ResizeObserver(() => {
updatePosition();
});
if (segment) {
resizeObserver.observe(segment);
}
// 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 a small set of thresholds to get periodic position updates during scroll
const thresholds = [0, 0.25, 0.5, 0.75, 1];
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();
try {
localStorage.setItem('file-view-sidebar-visible', 'false');
} catch {
// Ignore storage errors (e.g., disabled or full localStorage)
}
} else {
showSidebar();
try {
localStorage.setItem('file-view-sidebar-visible', 'true');
} catch {
// Ignore storage errors (e.g., disabled or full localStorage)
}
}
});
}
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
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
if (!plugin) return;
const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
showRenderRawFileButton(elFileView, renderContainer);
// maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
});
}