0 directories, 46 files

util

Home / tinai / util
/**
 * Overlay layer for viewing code blocks enlarged with line numbers and
 * optional highlighting of a line range or of code analysis findings.
 */

import { showOverlay, hideOverlay } from './overlay-utils.js';
import { addSafeEventListener, getEl, queryEl, queryAll } from './dom-utils.js';
import { build_thinking_block, escapeHtml, hljs, formatThinkingLineHtml, typesetMathjax } from './format-utils.js';
import { formatAnnotationsBlock, formatCostSummary } from './conversation-format-utils.js';
import { customConfirm } from './confirm-dialog.js';
import { append_following_bottom, content_of, scroller_of } from './scrollbars.js';

let div_overlay = null;
let div_view = null;
let div_thinking = null;
let div_items = null;
let div_side = null;

let current_lines = [];
let current_code = '';
let current_language = '';
let current_hash = '';
let current_items = [];
let current_analysis_els = [];
let selected_index = -1;
let side_open = false;
let on_analyze = null;
let load_analysis = null;
let save_analysis = null;
let single_column_query = window.matchMedia('(max-width: 780px)');

/**
 * Checks whether the code overlay is currently open.
 * @returns {boolean} True if the overlay is active.
 */
export function is_code_overlay_open() {
	return !!div_overlay && div_overlay.classList.contains('active');
}

/**
 * Checks whether the app layout is in single-column mode. Mirrors the CSS
 * breakpoint that collapses the app layout to a single column, so overlay
 * behavior keyed on this stays in sync with the stylesheet.
 * @returns {boolean} True when the viewport is at or below the breakpoint.
 */
function is_single_column() {
	return single_column_query.matches;
}

/**
 * Opens the overlay showing the given code with line numbers. Restores any
 * previously cached analysis for this code block.
 * @param {string} code - The raw code text.
 * @param {string} language - The highlight.js language name, may be empty.
 */
export function open_code_overlay(code, language) {
	div_overlay = getEl('id-code-overlay');
	div_view = getEl('id-code-overlay-view');
	div_thinking = getEl('id-code-overlay-thinking');
	div_items = getEl('id-code-overlay-items');
	div_side = queryEl('.div-code-overlay-side', div_overlay);
	if (!div_overlay || !div_view) return;

	current_code = (code || '').replace(/\n$/, '');
	current_language = language || '';
	current_hash = hash_analysis_key(current_language, current_code);
	current_lines = build_code_lines(current_code, current_language);
	content_of(div_view).innerHTML = '';
	const lines_wrap = document.createElement('div');
	lines_wrap.className = 'div-code-overlay-lines' + (current_language ? ` language-${current_language}` : '');
	for (const row of current_lines) {
		lines_wrap.appendChild(row);
	}
	content_of(div_view).appendChild(lines_wrap);

	const cached = typeof load_analysis === 'function' ? load_analysis(current_hash) : null;
	if (cached && Array.isArray(cached.items)) {
		restore_analysis(cached);
	} else {
		clear_results();
	}
	showOverlay(div_overlay, 'flex');
}

/**
 * Computes the stable cache key for a code block from its language and text.
 * @param {string} language - The highlight.js language name.
 * @param {string} code - The normalized code text.
 * @returns {string} The hash key.
 */
function hash_analysis_key(language, code) {
	const text = (language || '') + '\u0000' + (code || '');
	let hash = 5381;
	for (let i = 0; i < text.length; i++) {
		hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
	}
	return (hash >>> 0).toString(36) + '_' + text.length.toString(36);
}

/**
 * Restores a previously cached analysis into the right column.
 * @param {{items: Array, annotations?: Array, thinking?: string|string[], costs?: Object, model?: string, model_key?: string, tokens?: Object, tools?: Array, specialFeatures?: Array}} cached - The cached analysis data.
 */
function restore_analysis(cached) {
	current_items = cached.items;
	current_analysis_els = [];
	selected_index = -1;
	set_side_open(false);
	div_overlay.classList.add('column-open', 'results-shown');

	const lines = normalize_thinking(cached.thinking);
	if (lines.length > 0) {
		div_thinking.style.display = 'block';
		content_of(div_thinking).innerHTML = build_thinking_block('Analysis Consideration', lines.map(l => formatThinkingLineHtml(l)).join(''), false);
	} else {
		div_thinking.style.display = 'none';
		content_of(div_thinking).innerHTML = '';
	}

	div_items.style.display = 'block';
	content_of(div_items).innerHTML = '';
	render_analysis_items(cached.items);
	render_analysis_sources(Array.isArray(cached.annotations) ? cached.annotations : []);
	render_analysis_costs(cached);
	void typesetMathjax(div_items);

	const analyze_btn = getEl('btn-code-overlay-analyze');
	if (analyze_btn) {
		analyze_btn.textContent = 'Re-Analyze';
		analyze_btn.disabled = false;
	}
}

/**
 * Closes the code overlay if it is open.
 */
export function close_code_overlay() {
	if (!is_code_overlay_open()) return;
	hideOverlay(div_overlay);
}

/**
 * Binds all overlay interaction events. Call once during app initialization.
 * @param {Object} [handlers={}] - Overlay handlers.
 * @param {Function} [handlers.on_analyze] - Async handler called with
 *   (code, language, on_thinking) when Analyze is clicked; resolves to the
 *   analyze API response.
 * @param {Function} [handlers.load_analysis] - Returns cached analysis data
 *   for a code block hash, or null.
 * @param {Function} [handlers.save_analysis] - Persists analysis data for a
 *   code block hash.
 * @param {number} [handlers.single_column_breakpoint] - Viewport width below
 *   which the layout is single-column; must match the stylesheet breakpoint.
 */
export function init_code_overlay(handlers = {}) {
	div_overlay = getEl('id-code-overlay');
	div_view = getEl('id-code-overlay-view');
	div_thinking = getEl('id-code-overlay-thinking');
	div_items = getEl('id-code-overlay-items');
	div_side = queryEl('.div-code-overlay-side', div_overlay);
	if (!div_overlay || !div_view) return;

	on_analyze = typeof handlers.on_analyze === 'function' ? handlers.on_analyze : null;
	load_analysis = typeof handlers.load_analysis === 'function' ? handlers.load_analysis : null;
	save_analysis = typeof handlers.save_analysis === 'function' ? handlers.save_analysis : null;
	if (Number.isFinite(handlers.single_column_breakpoint)) {
		single_column_query = window.matchMedia('(max-width: ' + handlers.single_column_breakpoint + 'px)');
	}

	addSafeEventListener(getEl('btn-code-overlay-close'), 'click', close_code_overlay);
	addSafeEventListener(getEl('btn-code-overlay-analyze'), 'click', on_analyze_click);
	addSafeEventListener(getEl('btn-code-overlay-side'), 'click', on_side_toggle);
	addSafeEventListener(div_side, 'click', on_side_click, true);
	for (const btn of document.querySelectorAll('[data-code-nav]')) {
		addSafeEventListener(btn, 'click', () => on_nav_click(btn.dataset.codeNav));
	}
}

/**
 * Adds an expand button to every code block in the container. The button
 * opens the code overlay for that block.
 * @param {HTMLElement} container - Container holding pre.code-block elements.
 */
export function attach_code_expand_buttons(container) {
	if (!container) return;
	for (const pre of queryAll('pre.code-block', container)) {
		if (pre.closest('.div-diagram-mermaid, .mermaid')) continue;
		const code = pre.querySelector('code');
		if (!code || pre.querySelector('.div-code-expand-btn')) continue;
		const btn = document.createElement('button');
		btn.type = 'button';
		btn.className = 'div-code-expand-btn as-icon';
		btn.title = 'Expand Code';
		btn.textContent = '🔍';
		btn.onclick = () => open_code_overlay(code.textContent || '', get_code_language(code));
		pre.appendChild(btn);
	}
}

/**
 * Reads the highlight.js language from a code element's language- class.
 * @param {HTMLElement} code - The code element.
 * @returns {string} The language name or an empty string.
 */
function get_code_language(code) {
	const match = (code.className || '').match(/language-([\w-]+)/);
	return match ? match[1] : '';
}

/**
 * Builds one row element per code line with a line number and highlighted
 * content. Highlighting runs per line, so multi-line tokens are not colored
 * across lines.
 * @param {string} code - The raw code text.
 * @param {string} language - The highlight.js language name.
 * @returns {HTMLElement[]} The row elements.
 */
function build_code_lines(code, language) {
	return code.split('\n').map((line, i) => {
		const row = document.createElement('div');
		row.className = 'div-code-overlay-line';
		const num = document.createElement('span');
		num.className = 'div-code-overlay-line-num';
		num.textContent = String(i + 1);
		const codeEl = document.createElement('code');
		codeEl.innerHTML = highlight_line(line, language);
		row.append(num, codeEl);
		return row;
	});
}

/**
 * Highlights a single code line, falling back to escaped plain text.
 * @param {string} line - The code line.
 * @param {string} language - The highlight.js language name.
 * @returns {string} HTML for the line content.
 */
function highlight_line(line, language) {
	if (language) {
		try {
			return hljs.highlight(line, { language: language, ignoreIllegals: true }).value;
		} catch (e) {
			console.warn('Highlight.js failed to highlight line:', e);
		}
	}
	return escapeHtml(line);
}

/**
 * Slides the results panel in or out (single-column mode only).
 * @param {boolean} open - True to show the panel, false to leave only its edge visible.
 */
function set_side_open(open) {
	side_open = open;
	if (div_side) {
		div_side.classList.toggle('side-open', open);
	}
	const toggle_btn = getEl('btn-code-overlay-side');
	if (toggle_btn) {
		toggle_btn.textContent = open ? 'Close Analysis' : 'Open Analysis';
		toggle_btn.title = open ? 'Close Analysis' : 'Open Analysis';
	}
}

/**
 * Handles the Analysis button in the navigation bar: toggles the sliding
 * results panel (single-column mode only).
 */
function on_side_toggle() {
	if (!is_single_column()) return;
	set_side_open(!side_open);
}

/**
 * Handles a horizontal swipe while the code overlay is open by opening or
 * closing the sliding analysis column (single-column mode only).
 * @param {boolean} open - True to open the analysis column, false to close it.
 * @returns {boolean} True if the overlay is open and handled the swipe.
 */
export function swipe_code_overlay_analysis(open) {
	if (!is_code_overlay_open() || !is_single_column()) return false;
	set_side_open(open);
	return true;
}

/**
 * Handles taps on the results panel in single-column mode: tapping the visible
 * edge sliver opens the panel, tapping its left edge strip closes it. Taps on
 * the panel body while open fall through to the items underneath.
 * @param {MouseEvent} e - The click event.
 */
function on_side_click(e) {
	if (!div_side || !div_overlay || !is_single_column()) return;
	if (!div_overlay.classList.contains('column-open')) return;
	if (!side_open) {
		set_side_open(true);
		e.stopPropagation();
		return;
	}
	const rect = div_side.getBoundingClientRect();
	const edge = parseFloat(getComputedStyle(div_side).fontSize) * 2;
	if (e.clientX - rect.left <= edge) {
		set_side_open(false);
		e.stopPropagation();
	}
}

/**
 * Removes the highlight from all code lines.
 */
function clear_line_highlights() {
	for (const row of current_lines) {
		row.classList.remove('code-line-highlighted');
	}
}

/**
 * Applies the highlight to a 1-based inclusive range of code lines.
 * @param {number} from - First line number.
 * @param {number} to - Last line number.
 */
function apply_line_highlight(from, to) {
	for (let i = Math.max(1, from); i <= Math.min(current_lines.length, to); i++) {
		current_lines[i - 1].classList.add('code-line-highlighted');
	}
}

/**
 * Hides and clears the thinking and analysis results column, resets the
 * header state, and re-enables the Analyze button.
 */
function clear_results() {
	if (div_overlay) {
		div_overlay.classList.remove('column-open', 'results-shown');
	}
	set_side_open(false);
	if (div_thinking) {
		div_thinking.style.display = 'none';
		content_of(div_thinking).innerHTML = '';
	}
	if (div_items) {
		div_items.style.display = 'none';
		content_of(div_items).innerHTML = '';
	}
	const analyze_btn = getEl('btn-code-overlay-analyze');
	if (analyze_btn) {
		analyze_btn.disabled = false;
		analyze_btn.textContent = 'Analyze';
	}
	current_items = [];
	current_analysis_els = [];
	selected_index = -1;
}

/**
 * Handles the Previous/Next/Clear navigation buttons.
 * @param {string} action - One of 'prev', 'next', 'clear'.
 */
function on_nav_click(action) {
	if (!div_items) return;
	if (action === 'clear') {
		clear_selection();
		return;
	}
	if (current_analysis_els.length === 0) return;
	if (action === 'prev') {
		select_analysis_item(selected_index < 0 ? current_analysis_els.length - 1 : selected_index - 1);
	} else if (action === 'next') {
		select_analysis_item(selected_index + 1);
	}
}

/**
 * Removes the selection from the analysis items and the code lines.
 */
function clear_selection() {
	for (const el of current_analysis_els) {
		el.classList.remove('selected');
	}
	clear_line_highlights();
	selected_index = -1;
}

/**
 * Normalizes the thinking field into an array of lines.
 * @param {string|string[]|undefined} thinking - Thought lines or newline-separated thoughts.
 * @returns {string[]} The thought lines.
 */
function normalize_thinking(thinking) {
	if (Array.isArray(thinking)) return thinking.map(String);
	if (typeof thinking === 'string' && thinking.length > 0) return thinking.split('\n').filter(l => l.length > 0);
	return [];
}

/**
 * Handles the Analyze button click: opens the results column, submits the
 * code to the analyze handler, streams the thinking, and renders the findings.
 * Once results are shown, asks for confirmation before re-analyzing.
 */
async function on_analyze_click() {
	if (!on_analyze || !div_items || !div_thinking || !div_overlay) return;
	if (div_overlay.classList.contains('results-shown')) {
		const confirmed = await customConfirm('Re-Analyze', 'Do you want to re-analyze this code with the current conversation settings?');
		if (!confirmed) return;
	}
	const analyze_btn = getEl('btn-code-overlay-analyze');
	if (analyze_btn) {
		analyze_btn.disabled = true;
	}

	div_overlay.classList.add('column-open');
	current_items = [];
	current_analysis_els = [];
	selected_index = -1;
	set_side_open(false);
	clear_line_highlights();
	div_items.style.display = 'block';
	content_of(div_items).innerHTML = '';
	div_thinking.style.display = 'block';
	content_of(div_thinking).innerHTML = build_thinking_block('Analyzing...');
	const thinking_content = div_thinking.querySelector('.instant-answer-thoughts-content');
	const streamed_lines = [];

	const on_thinking = (thoughtText) => {
		if (!thoughtText || !thinking_content || !div_thinking) return;
		const lines = String(thoughtText).split('\n').filter(l => l.length > 0);
		streamed_lines.push(...lines);
		append_following_bottom(scroller_of(div_thinking), () => thinking_content.insertAdjacentHTML('beforeend', lines.map(l => formatThinkingLineHtml(l)).join('')));
	};

	try {
		const response = await on_analyze(current_code, current_language, on_thinking);
		const details = div_thinking.querySelector('details');
		if (details) {
			const summary = details.querySelector('summary');
			if (summary) {
				summary.textContent = 'Analysis Consideration';
			}
			details.open = false;
		}
		if (response && response.error) {
			content_of(div_items).innerHTML = `<span class="span-status-error">${escapeHtml(response.error)}</span>`;
			return;
		}
		const final_lines = normalize_thinking(response?.thinking);
		const lines = final_lines.length > 0 ? final_lines : streamed_lines;
		if (thinking_content) {
			thinking_content.classList.remove('streaming');
			if (lines.length > 0) {
				thinking_content.innerHTML = lines.map(l => formatThinkingLineHtml(l)).join('');
			} else {
				div_thinking.style.display = 'none';
			}
		}
		const items = Array.isArray(response) ? response : (Array.isArray(response?.items) ? response.items : []);
		current_items = items;
		current_analysis_els = [];
		selected_index = -1;
		render_analysis_items(items);
		render_analysis_sources(Array.isArray(response?.annotations) ? response.annotations : []);
		render_analysis_costs(response);
		await typesetMathjax(div_items);
		div_overlay.classList.add('results-shown');
		if (analyze_btn) {
			analyze_btn.textContent = 'Re-Analyze';
		}
		if (typeof save_analysis === 'function' && current_hash) {
			save_analysis(current_hash, {
				items: items,
				annotations: Array.isArray(response?.annotations) ? response.annotations : [],
				thinking: lines,
				costs: response?.costs,
				model: response?.model,
				model_key: response?.model_key,
				tokens: response?.tokens,
				tools: response?.tools,
				specialFeatures: response?.specialFeatures,
				timestamp: Date.now()
			});
		}
	} catch (e) {
		console.error('Code analysis failed:', e);
		content_of(div_items).innerHTML = '<span class="span-status-error">Code analysis failed.</span>';
	} finally {
		if (analyze_btn) {
			analyze_btn.disabled = false;
		}
	}
}

/**
 * Renders the analysis findings as clickable items.
 * @param {Array<{lineStart: number, lineEnd: number, type: string, title: string, analysis: string}>} items
 */
function render_analysis_items(items) {
	content_of(div_items).innerHTML = '';
	if (!Array.isArray(items) || items.length === 0) {
		content_of(div_items).innerHTML = '<em>No findings.</em>';
		return;
	}
	for (const item of items) {
		if (!item || typeof item !== 'object') continue;
		const el = document.createElement('div');
		el.className = 'code-analysis-item';
		el.dataset.type = get_item_type(item);
		const title = document.createElement('div');
		title.className = 'code-analysis-item-title';
		const type = document.createElement('span');
		type.className = 'code-analysis-item-type';
		type.textContent = get_item_type(item);
		title.append(type, document.createTextNode(item.title || 'Finding'));
		const body = document.createElement('div');
		body.className = 'code-analysis-item-analysis';
		body.innerHTML = sanitize_analysis_html(item.analysis);
		el.append(title, body);
		const index = current_analysis_els.length;
		current_analysis_els.push(el);
		el.onclick = () => select_analysis_item(index);
		content_of(div_items).appendChild(el);
	}
}

/**
 * Returns the normalized item type, one of EXPLANATION, CAUTION, WARNING.
 * @param {{type?: string}} item - The analysis item.
 * @returns {string} The item type.
 */
function get_item_type(item) {
	const type = String(item.type || '').toUpperCase();
	return ['EXPLANATION', 'CAUTION', 'WARNING'].includes(type) ? type : 'EXPLANATION';
}

/**
 * Renders the search source links below the items list.
 * @param {Array<{title: string, url: string}>} annotations - Source citations.
 */
function render_analysis_sources(annotations) {
	const sources_html = formatAnnotationsBlock(annotations);
	if (sources_html) {
		content_of(div_items).insertAdjacentHTML('beforeend', sources_html);
	}
}

/**
 * Renders the cost summary below the items list.
 * @param {object} response - The full analyze API response.
 */
function render_analysis_costs(response) {
	const costs_html = formatCostSummary(response);
	if (costs_html) {
		content_of(div_items).insertAdjacentHTML('beforeend', costs_html);
	}
}

/**
 * Selects an analysis item by index and highlights its code lines.
 * @param {number} index - The item index.
 */
function select_analysis_item(index) {
	if (index < 0 || index >= current_analysis_els.length) return;
	for (const other of current_analysis_els) {
		other.classList.remove('selected');
	}
	const el = current_analysis_els[index];
	const item = current_items[index];
	el.classList.add('selected');
	clear_line_highlights();
	if (item) {
		const from = Math.min(item.lineStart, item.lineEnd);
		const to = Math.max(item.lineStart, item.lineEnd);
		apply_line_highlight(from, to);
		scroll_code_to_range(from, to);
	}
	selected_index = index;
	scroll_item_into_view(el);
}

/**
 * Scrolls the findings list so the given item is visible. Done manually
 * because scrollIntoView would also scroll the overflow:hidden overlay
 * ancestors, shifting the layout and the sliding results panel.
 * @param {HTMLElement} el - The analysis item element.
 */
function scroll_item_into_view(el) {
	if (!div_items) return;
	const sc = scroller_of(div_items);
	const top = el.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop;
	if (top < sc.scrollTop) {
		sc.scrollTop = top;
	} else if (top + el.offsetHeight > sc.scrollTop + sc.clientHeight) {
		sc.scrollTop = top + el.offsetHeight - sc.clientHeight;
	}
}

/**
 * Scrolls the code view so the highlighted line range is in view, vertically
 * centered when the whole region fits in the viewport, and aligned to the top
 * of the viewport otherwise.
 * @param {number} from - First highlighted line number.
 * @param {number} to - Last highlighted line number.
 */
function scroll_code_to_range(from, to) {
	if (!div_view || current_lines.length === 0) return;
	const first = current_lines[Math.max(1, from) - 1];
	const last = current_lines[Math.min(current_lines.length, to) - 1];
	if (!first || !last) return;

	const sc = scroller_of(div_view);
	const view_rect = sc.getBoundingClientRect();
	const first_rect = first.getBoundingClientRect();
	const last_rect = last.getBoundingClientRect();
	const region_top = first_rect.top - view_rect.top + sc.scrollTop;
	const region_height = last_rect.bottom - first_rect.top;

	let target;
	if (region_height <= view_rect.height) {
		target = region_top - (view_rect.height - region_height) / 2;
	} else {
		target = region_top;
	}
	sc.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}

/**
 * Sanitizes analysis HTML to the instant answer element whitelist minus
 * links: drops a elements and inline event handlers.
 * @param {string} html - The raw analysis HTML.
 * @returns {string} The sanitized HTML.
 */
function sanitize_analysis_html(html) {
	return String(html || '')
		.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
		.replace(/<\/?a\b[^>]*>/gi, '')
		.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
}
🌐
code-overlay.js ×
Type: Web, text/x-java
21.77 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File