feat: make file picker embeddable
This commit is contained in:
@@ -63,13 +63,13 @@ export function initUploadTable(cfg) {
|
||||
if (toggle) {
|
||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||
if (found) found.node.expanded = !found.node.expanded;
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
removeNode(state, remove.dataset.remove);
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function initUploadTable(cfg) {
|
||||
getFiles: () => flattenFiles(state.nodes),
|
||||
remove: (id) => {
|
||||
removeNode(state, id);
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
},
|
||||
render: () => render(state, elements),
|
||||
clear: () => {
|
||||
|
||||
@@ -30,15 +30,16 @@ export async function addFiles(state, cfg, files, elements) {
|
||||
}
|
||||
try {
|
||||
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt);
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||
if (typeof cfg.onError === 'function') cfg.onError(error, file);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,13 +53,14 @@ export function onFolderChange(state, cfg, elements) {
|
||||
if (lowerPath.endsWith('.zip')) {
|
||||
try {
|
||||
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
|
||||
const zip = await listZipFiles(file, cfg.allowedExt);
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||
if (typeof cfg.onError === 'function') cfg.onError(error, file);
|
||||
continue;
|
||||
}
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
@@ -71,7 +72,7 @@ export function onFolderChange(state, cfg, elements) {
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
// Сброс value позволяет выбрать тот же каталог повторно.
|
||||
elements.folderInputEl.value = '';
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
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-строки таблицы.
|
||||
*
|
||||
@@ -12,7 +37,7 @@ import { fs } from './fs.js';
|
||||
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
|
||||
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
|
||||
*/
|
||||
function renderNode(node, depth) {
|
||||
function renderNode(node, depth, cfg) {
|
||||
const isGroup = node.kind !== 'file';
|
||||
const padding = depth * 4;
|
||||
const name = esc(node.name);
|
||||
@@ -20,14 +45,22 @@ function renderNode(node, depth) {
|
||||
? `<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="Удалить ${name}">×</button>`;
|
||||
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 row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}><td>${action}</td>`
|
||||
+ `<td>${node.kind === 'file' ? fs(node.file.size) : ''}</td>`
|
||||
+ `<td>${node.error ? esc(node.error) : node.kind === 'file' ? 'готов' : ''}</td><td>${remove}</td></tr>`;
|
||||
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)).join('');
|
||||
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,10 +73,14 @@ function renderNode(node, depth) {
|
||||
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
|
||||
* в количество leaf-файлов и суммарный размер.
|
||||
*/
|
||||
export function render(state, elements) {
|
||||
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)).join('')
|
||||
: '<tr><td colspan="4">Нет выбранных файлов</td></tr>';
|
||||
? 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) => {
|
||||
@@ -51,7 +88,8 @@ export function render(state, elements) {
|
||||
else node.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
elements.countEl.textContent = `${files.length} файлов · ${fs(files.reduce((sum, node) => sum + node.file.size, 0))}`;
|
||||
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user