0 directories, 46 files

util

Home / tinai / util
/**
 * Overlay layer for viewing conversation tables enlarged with an as-you-type
 * case-insensitive row filter, term highlighting, and click-to-sort headers.
 */

import { showOverlay, hideOverlay } from './overlay-utils.js';
import { addSafeEventListener, getEl, queryAll, queryEl } from './dom-utils.js';
import { content_of } from './scrollbars.js';

let div_overlay = null;
let div_title = null;
let div_view = null;
let input_filter = null;

let table_data = null;

/**
 * Currently known filter and sort parameters. Held in memory only, so they
 * persist across overlay opens until the app is closed.
 */
const table_params = {
	filter: '',
	sort_col: -1,
	sort_dir: 'asc'
};

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

/**
 * Opens the overlay showing the given table JSON structure.
 * @param {{caption?: string, 'has-header'?: boolean, 'table-rows'?: Array<Array>}} data - The table structure.
 */
export function open_table_overlay(data) {
	if (!data || !Array.isArray(data['table-rows'])) return;
	div_overlay = getEl('id-table-overlay');
	div_title = getEl('id-table-overlay-title');
	div_view = content_of(getEl('id-table-overlay-view'));
	input_filter = getEl('id-input-table-overlay-filter');
	if (!div_overlay || !div_view || !input_filter) return;

	table_data = data;
	input_filter.value = table_params.filter;
	render_table();
	showOverlay(div_overlay, 'flex');
}

/**
 * Closes the table overlay if it is open.
 */
export function close_table_overlay() {
	if (!is_table_overlay_open()) return;
	hideOverlay(div_overlay);
}

/**
 * Binds all overlay interaction events. Call once during app initialization.
 */
export function init_table_overlay() {
	div_overlay = getEl('id-table-overlay');
	div_title = getEl('id-table-overlay-title');
	div_view = content_of(getEl('id-table-overlay-view'));
	input_filter = getEl('id-input-table-overlay-filter');
	if (!div_overlay || !div_view || !input_filter) return;

	addSafeEventListener(getEl('btn-table-overlay-close'), 'click', close_table_overlay);
	addSafeEventListener(input_filter, 'input', () => {
		table_params.filter = input_filter.value;
		render_table();
	});
}

/**
 * Adds an expand button to every conversation table block in the container.
 * The button opens the table overlay for that table.
 * @param {HTMLElement} container - Container holding .div-table-block elements.
 */
export function attach_table_expand_buttons(container) {
	if (!container) return;
	for (const block of queryAll('.div-table-block', container)) {
		if (block.querySelector('.div-table-expand-btn')) continue;
		const header = queryEl('.div-table-block-header', block) || block;
		const btn = document.createElement('button');
		btn.type = 'button';
		btn.className = 'div-table-expand-btn as-icon';
		btn.title = 'Expand Table';
		btn.textContent = '🔍';
		btn.onclick = () => {
			let data = null;
			try {
				data = JSON.parse(block.getAttribute('data-table') || 'null');
			} catch (e) {
				data = null;
			}
			if (data) open_table_overlay(data);
		};
		header.appendChild(btn);
	}
}

/**
 * Renders the table from a filtered and sorted copy of the table JSON
 * structure, then highlights the filter term in the visible cell text.
 */
function render_table() {
	if (!table_data || !div_view || !div_title) return;

	if (table_data.caption) {
		div_title.innerHTML = table_data.caption;
		div_title.style.display = 'block';
	} else {
		div_title.style.display = 'none';
	}

	const rows = table_data['table-rows'].map(row => Array.isArray(row) ? row.slice() : []);
	const has_header = Boolean(table_data['has-header']) && rows.length > 0;
	const header = has_header ? rows.shift() : [];

	const filter = table_params.filter.trim().toLowerCase();
	let body_rows = rows;
	if (filter) {
		body_rows = body_rows.filter(row => row.some(cell => get_cell_text(cell).toLowerCase().includes(filter)));
	}
	if (table_params.sort_col >= 0) {
		sort_rows(body_rows, table_params.sort_col, table_params.sort_dir);
	}

	let html = '';
	html += '<div class="div-table-overlay-tablewrap">';
	html += '<table>';
	if (has_header) {
		html += '<thead><tr>';
		header.forEach((title, col) => {
			html += '<th data-col="' + col + '">' + title + get_sort_indicator(col) + '</th>';
		});
		html += '</tr></thead>';
	}
	html += '<tbody>';
	for (const row of body_rows) {
		html += '<tr>';
		for (const cell of row) html += '<td>' + cell + '</td>';
		html += '</tr>';
	}
	html += '</tbody></table>';
	html += '</div>';

	div_view.innerHTML = html;

	if (filter) {
		highlight_filter(queryEl('tbody', div_view), filter);
	}
	for (const th of queryAll('th[data-col]', div_view)) {
		addSafeEventListener(th, 'click', () => on_header_click(parseInt(th.getAttribute('data-col'), 10)));
	}
}

/**
 * Returns the right-aligned sort indicator for a header column: an arrow when
 * that column is sorted, a small dot otherwise.
 * @param {number} col - The column index.
 * @returns {string} The indicator HTML.
 */
function get_sort_indicator(col) {
	if (table_params.sort_col !== col) {
		return '<span class="span-th-sort span-th-sort-none">&middot;</span>';
	}
	const arrow = (table_params.sort_dir === 'asc') ? '&#x25b4;' : '&#x25be;';
	return '<span class="span-th-sort">' + arrow + '</span>';
}

/**
 * Handles a header click: cycles the sort column through ascending,
 * descending, and no sorting, then re-renders.
 * @param {number} col - The clicked column index.
 */
function on_header_click(col) {
	if (isNaN(col)) return;
	if (table_params.sort_col === col) {
		if (table_params.sort_dir === 'asc') {
			table_params.sort_dir = 'desc';
		} else {
			table_params.sort_col = -1;
			table_params.sort_dir = 'asc';
		}
	} else {
		table_params.sort_col = col;
		table_params.sort_dir = 'asc';
	}
	render_table();
}

/**
 * Sorts the rows in place by the text of the given column, numerically when
 * both values are numeric, otherwise case-insensitively as text.
 * @param {Array<Array>} rows - The rows to sort.
 * @param {number} col - The column index to sort by.
 * @param {string} dir - 'asc' or 'desc'.
 */
function sort_rows(rows, col, dir) {
	const factor = (dir === 'desc') ? -1 : 1;
	rows.sort((a, b) => {
		const a_val = get_cell_text(a[col]).trim();
		const b_val = get_cell_text(b[col]).trim();
		const a_num = parseFloat(a_val);
		const b_num = parseFloat(b_val);
		let result;
		if (!isNaN(a_num) && !isNaN(b_num) && a_val !== '' && b_val !== '') {
			result = a_num - b_num;
		} else {
			result = a_val.toLowerCase().localeCompare(b_val.toLowerCase());
		}
		return result * factor;
	});
}

/**
 * Reads the visible text of a table cell, stripping any HTML markup.
 * @param {*} cell - The raw cell value.
 * @returns {string} The cell text.
 */
function get_cell_text(cell) {
	if (cell === null || cell === undefined) return '';
	const el = document.createElement('span');
	el.innerHTML = String(cell);
	return el.textContent || '';
}

/**
 * Wraps every case-insensitive occurrence of the filter term in the text
 * nodes of the given root with a <mark> element.
 * @param {HTMLElement|null} root - The element to highlight within.
 * @param {string} filter - The lowercased filter term.
 */
function highlight_filter(root, filter) {
	if (!root || !filter) return;
	const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
	const text_nodes = [];
	while (walker.nextNode()) text_nodes.push(walker.currentNode);

	for (const node of text_nodes) {
		const text = node.nodeValue;
		const lower = text.toLowerCase();
		if (!lower.includes(filter)) continue;

		const fragment = document.createDocumentFragment();
		let pos = 0;
		let idx = lower.indexOf(filter);
		while (idx >= 0) {
			if (idx > pos) fragment.appendChild(document.createTextNode(text.substring(pos, idx)));
			const mark = document.createElement('mark');
			mark.textContent = text.substring(idx, idx + filter.length);
			fragment.appendChild(mark);
			pos = idx + filter.length;
			idx = lower.indexOf(filter, pos);
		}
		if (pos < text.length) fragment.appendChild(document.createTextNode(text.substring(pos)));
		node.parentNode.replaceChild(fragment, node);
	}
}
🌐
table-overlay.js ×
Type: Web, text/x-java
8.10 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File