124 lines
5.7 KiB
JavaScript
124 lines
5.7 KiB
JavaScript
import { esc } from './esc.js';
|
||
import { fs } from './fs.js';
|
||
|
||
const DEFAULT_RENDER_CONFIG = {
|
||
labels: {
|
||
statusReady: 'готов',
|
||
remove: 'Удалить',
|
||
empty: 'Нет выбранных файлов',
|
||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
|
||
},
|
||
layout: { columns: ['path', 'size', 'status'] },
|
||
};
|
||
|
||
function renderConfig(cfg = {}) {
|
||
return {
|
||
labels: {
|
||
...DEFAULT_RENDER_CONFIG.labels,
|
||
...(cfg.labels || {}),
|
||
columns: {
|
||
...DEFAULT_RENDER_CONFIG.labels.columns,
|
||
...(cfg.labels?.columns || {}),
|
||
},
|
||
},
|
||
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
|
||
*
|
||
* @param {{kind: string, id: string, name: string, path: string, file?: File,
|
||
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
|
||
* @param {number} depth Глубина узла; используется для визуального отступа.
|
||
* @returns {string} HTML одной строки или поддерева строк.
|
||
*
|
||
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
|
||
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
|
||
*/
|
||
function renderNode(node, depth, cfg) {
|
||
const isGroup = node.kind !== 'file';
|
||
const padding = depth * 4;
|
||
const name = esc(node.name);
|
||
const action = isGroup
|
||
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
|
||
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
||
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
|
||
const cells = {
|
||
path: action,
|
||
size: node.kind === 'file' ? fs(node.file.size) : '',
|
||
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
|
||
};
|
||
const columns = cfg.layout.columns?.length
|
||
? cfg.layout.columns
|
||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
|
||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||
+ `<td>${remove}</td></tr>`;
|
||
if (!isGroup || !node.expanded) return row;
|
||
// Дочерние строки добавляются только для раскрытой группы.
|
||
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
|
||
}
|
||
|
||
/**
|
||
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
|
||
*
|
||
* @param {{nodes: Array}} state Состояние дерева.
|
||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
|
||
* @returns {void}
|
||
*
|
||
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
|
||
* в количество leaf-файлов и суммарный размер.
|
||
*/
|
||
export function render(state, elements, cfg) {
|
||
const normalized = renderConfig(cfg);
|
||
const columns = normalized.layout.columns?.length
|
||
? normalized.layout.columns
|
||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||
elements.tableBodyEl.innerHTML = state.nodes.length
|
||
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
|
||
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||
const files = [];
|
||
// Собирает все leaf-файлы, включая содержимое свернутых групп.
|
||
const visit = (node) => {
|
||
if (node.kind === 'file') files.push(node);
|
||
else node.children.forEach(visit);
|
||
};
|
||
state.nodes.forEach(visit);
|
||
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
|
||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||
}
|
||
|
||
/**
|
||
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
|
||
*
|
||
* @param {Array} nodes Массив узлов текущего уровня.
|
||
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
|
||
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
|
||
*/
|
||
export function findNode(nodes, id) {
|
||
for (const node of nodes) {
|
||
if (node.id === id) return { node, nodes };
|
||
const found = findNode(node.children || [], id);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Преобразует иерархическое дерево в плоский список leaf-файлов.
|
||
*
|
||
* @param {Array} nodes Узлы любого уровня.
|
||
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
|
||
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
|
||
*/
|
||
export function flattenFiles(nodes, result = []) {
|
||
nodes.forEach((node) => {
|
||
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
|
||
else flattenFiles(node.children, result);
|
||
});
|
||
return result;
|
||
} |