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) ? `` : ''; const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns); const headings = columns.map((column) => `${esc(cfg.labels.columns[column] || column)}`).join(''); root.innerHTML = `
${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')}

${headings}

`; } 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'), statusEl: root.querySelector('.fp-status'), 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 { putToVm } from './upload/put_to_vm.js'; export { uploadViaVM } from './upload/upload_via_vm.js'; export { DEFAULTS };