190 lines
8.0 KiB
JavaScript
190 lines
8.0 KiB
JavaScript
import { addFiles, onFilesChange } from './table/on_files_change.js';
|
|
import { onFolderChange } from './table/on_folder_change.js';
|
|
import { findNode, flattenFiles, render } from './table/render.js';
|
|
import { esc } from './table/esc.js';
|
|
|
|
const DEFAULTS = {
|
|
allowedExt: [],
|
|
labels: {
|
|
pickFiles: 'Выбрать файлы',
|
|
pickFolder: 'Выбрать папку',
|
|
clear: 'Очистить',
|
|
empty: 'Нет выбранных файлов',
|
|
statusReady: 'готов',
|
|
remove: 'Удалить',
|
|
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
|
count: (count, bytes) => `${count} файлов · ${bytes} B`,
|
|
},
|
|
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
|
|
limits: {
|
|
maxEntries: 1000,
|
|
maxTotalBytes: 100 * 1024 * 1024,
|
|
maxEntryBytes: 50 * 1024 * 1024,
|
|
maxDepth: 20,
|
|
},
|
|
};
|
|
|
|
function resolveMount(mount) {
|
|
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
|
|
if (!element) throw new Error('File picker mount element не найден');
|
|
return element;
|
|
}
|
|
|
|
function normalizeConfig(config) {
|
|
return {
|
|
...config,
|
|
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
|
|
labels: {
|
|
...DEFAULTS.labels,
|
|
...(config.labels || {}),
|
|
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
|
|
},
|
|
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
|
|
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
|
|
};
|
|
}
|
|
|
|
function injectStyles() {
|
|
if (document.querySelector('style[data-file-picker]')) return;
|
|
const style = document.createElement('style');
|
|
style.dataset.filePicker = '';
|
|
style.textContent = `
|
|
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
|
|
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
|
|
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
|
|
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
|
|
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
|
|
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
|
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
|
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
|
|
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
|
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
|
|
.file-picker .fp-count { min-height: 22px; }
|
|
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
|
|
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
|
|
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
|
|
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
|
|
.file-picker .tree-row td:first-child { white-space: nowrap; }
|
|
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
|
|
`;
|
|
document.head.append(style);
|
|
}
|
|
|
|
function buildMarkup(root, cfg) {
|
|
const controls = cfg.layout.controls || [];
|
|
const control = (name, id, label, className = '') => controls.includes(name)
|
|
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
|
|
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
|
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
|
|
root.innerHTML = `
|
|
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
|
|
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
|
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
|
|
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
|
|
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
|
|
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
|
|
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
|
|
</div>
|
|
<p class="fp-status" role="status"></p>
|
|
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
|
|
<p class="fp-count"></p>
|
|
</div>`;
|
|
}
|
|
|
|
function notify(cfg, state) {
|
|
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
|
|
}
|
|
|
|
function removeMetadata(state, node) {
|
|
if (node.kind === 'file') {
|
|
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
|
|
return;
|
|
}
|
|
node.children.forEach((child) => removeMetadata(state, child));
|
|
}
|
|
|
|
function removeNode(state, id) {
|
|
const found = findNode(state.nodes, id);
|
|
if (!found) return false;
|
|
removeMetadata(state, found.node);
|
|
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
|
return true;
|
|
}
|
|
|
|
export function initFilePicker(config) {
|
|
const cfg = normalizeConfig(config);
|
|
const mount = resolveMount(cfg.mount);
|
|
injectStyles();
|
|
buildMarkup(mount, cfg);
|
|
const root = mount.firstElementChild;
|
|
const elements = {
|
|
fileInputEl: root.querySelector('.fp-file-input'),
|
|
folderInputEl: root.querySelector('.fp-folder-input'),
|
|
tableBodyEl: root.querySelector('.fp-table-body'),
|
|
countEl: root.querySelector('.fp-count'),
|
|
};
|
|
const state = { nodes: [], fileKeys: new Set(), busy: false };
|
|
const listeners = [];
|
|
const listen = (target, event, handler) => {
|
|
target.addEventListener(event, handler);
|
|
listeners.push(() => target.removeEventListener(event, handler));
|
|
};
|
|
const redraw = () => render(state, elements, cfg);
|
|
const filesChange = onFilesChange(state, cfg, elements);
|
|
const folderChange = onFolderChange(state, cfg, elements);
|
|
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
|
|
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
|
|
listen(elements.tableBodyEl, 'click', (event) => {
|
|
const toggle = event.target.closest('[data-toggle]');
|
|
if (toggle) {
|
|
const found = findNode(state.nodes, toggle.dataset.toggle);
|
|
if (found) found.node.expanded = !found.node.expanded;
|
|
redraw();
|
|
return;
|
|
}
|
|
const remove = event.target.closest('[data-remove]');
|
|
if (remove) {
|
|
if (removeNode(state, remove.dataset.remove)) {
|
|
redraw();
|
|
notify(cfg, state);
|
|
}
|
|
}
|
|
});
|
|
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
|
|
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
|
|
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
|
|
state.nodes = [];
|
|
state.fileKeys.clear();
|
|
redraw();
|
|
notify(cfg, state);
|
|
});
|
|
redraw();
|
|
return {
|
|
pickFiles: () => elements.fileInputEl.click(),
|
|
pickFolder: () => elements.folderInputEl.click(),
|
|
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
|
|
getFiles: () => [...flattenFiles(state.nodes)],
|
|
remove: (id) => {
|
|
if (removeNode(state, id)) {
|
|
redraw();
|
|
notify(cfg, state);
|
|
}
|
|
},
|
|
clear: () => {
|
|
state.nodes = [];
|
|
state.fileKeys.clear();
|
|
redraw();
|
|
notify(cfg, state);
|
|
},
|
|
render: redraw,
|
|
destroy: () => {
|
|
listeners.forEach((removeListener) => removeListener());
|
|
state.nodes = [];
|
|
state.fileKeys.clear();
|
|
mount.replaceChildren();
|
|
},
|
|
};
|
|
}
|
|
|
|
export { DEFAULTS };
|