import-picker.js (10152B)
1 import { fetchBytes } from './bundle.js'; 2 import { libraryIndex, latestLibraries, matchesLibrary } from './library-index.js'; 3 import { packageReleases } from './packages.js'; 4 export function importPicker(options) { 5 const get = (id) => document.getElementById(id); 6 const dialog = get('import-dialog'), open = get('add-import'); 7 const search = get('import-search'), results = get('import-results'), detail = get('import-detail'); 8 const alias = get('import-alias'), versions = get('import-version'); 9 const status = get('import-status'), preview = get('import-preview'), insert = get('insert-import'); 10 let libraries, selected, plan; 11 let worker, timer, debounce; 12 let revision = 0, snapshot = '', backend = ''; 13 const stop = () => { revision++; worker?.terminate(); worker = undefined; clearTimeout(timer); clearTimeout(debounce); }; 14 const finish = () => { stop(); dialog.close(); open.focus(); }; 15 const message = (text) => { status.textContent = text; }; 16 const statement = () => selected ? `!import ${selected.manifest} as ${alias.value}` : ''; 17 function check() { 18 stop(); 19 plan = undefined; 20 insert.disabled = true; 21 insert.textContent = 'Insert import'; 22 preview.textContent = statement(); 23 if (!selected) 24 return; 25 if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(alias.value)) { 26 message('Choose a namespace using letters, digits and underscores, starting with a letter or underscore.'); 27 return; 28 } 29 message('Checking with the native compiler…'); 30 const current = revision; 31 debounce = setTimeout(() => { 32 if (!dialog.open || current !== revision) 33 return; 34 const url = new URL('./wasm-worker.js', import.meta.url); 35 url.searchParams.set('backend', backend); 36 const next = new Worker(url, { type: 'module' }); 37 worker = next; 38 const fail = (text) => { if (current !== revision) 39 return; stop(); message(`${text} You can copy the import and finish editing the source.`); }; 40 timer = setTimeout(() => fail('Import check timed out.'), 60_000); 41 next.onerror = event => { event.preventDefault(); fail(event.message || 'Import check failed.'); }; 42 next.onmessage = ({ data }) => { 43 if (current !== revision || !dialog.open) 44 return; 45 if (data.type === 'error') { 46 fail(data.message ?? 'Import check failed.'); 47 return; 48 } 49 if (data.type !== 'import-checked' || !data.plan) 50 return; 51 plan = data.plan; 52 stop(); 53 insert.disabled = false; 54 insert.textContent = plan.existing ? 'Show existing import' : 'Insert import'; 55 message(plan.existing ? `Already imported ${plan.existing.alias === '*' ? 'openly' : 'as ' + plan.existing.alias}.` : 'Ready to insert.'); 56 }; 57 next.postMessage({ type: 'check-import', source: snapshot, manifest: selected.manifest, alias: alias.value, localModules: options.inputs() }); 58 }, 250); 59 } 60 function show(choice, focus = true) { 61 selected = choice; 62 get('import-actions').hidden = false; 63 results.hidden = true; 64 get('import-browse').hidden = true; 65 detail.hidden = false; 66 get('import-title').textContent = choice.title; 67 get('import-description').textContent = choice.description; 68 get('import-usage').replaceChildren(...(choice.library?.usage ?? '').split('`').map((text, i) => { 69 if (i % 2 === 0) 70 return document.createTextNode(text); 71 const code = document.createElement('code'); 72 code.textContent = text; 73 return code; 74 })); 75 get('import-usage-details').hidden = !choice.library?.usage; 76 get('import-example').textContent = choice.library?.example ?? ''; 77 get('import-example-details').hidden = !choice.library?.example; 78 get('import-usage-details').open = false; 79 get('import-example-details').open = false; 80 alias.value = choice.alias; 81 versions.replaceChildren(); 82 versions.hidden = !choice.library; 83 get('import-version-label').hidden = !choice.library; 84 if (choice.library) 85 for (const release of packageReleases(libraries, choice.library.name)) { 86 const option = new Option(release.version, release.version); 87 option.selected = release.version === choice.library.version; 88 versions.add(option); 89 } 90 const reference = get('import-reference'); 91 reference.hidden = !choice.library; 92 if (choice.library) 93 reference.href = new URL('../reference/' + choice.library.reference, import.meta.url).href; 94 get('import-export-search').value = ''; 95 renderExports(); 96 check(); 97 if (focus) { 98 get('import-content').scrollTop = 0; 99 get('import-title').focus({ preventScroll: true }); 100 } 101 } 102 function renderExports() { 103 const query = get('import-export-search').value.toLowerCase(); 104 get('import-exports').textContent = selected?.names.filter(n => n.toLowerCase().includes(query)).join('\n') || 'No matching exports.'; 105 } 106 function browse() { 107 stop(); 108 selected = undefined; 109 plan = undefined; 110 detail.hidden = true; 111 get('import-actions').hidden = true; 112 get('import-content').scrollTop = 0; 113 get('import-browse').hidden = false; 114 results.hidden = false; 115 message(''); 116 results.replaceChildren(); 117 const choices = [ 118 ...options.local().map(row => ({ title: row.label, description: 'Local file', manifest: row.manifest, names: row.names, alias: 'Local' })), 119 ...latestLibraries(libraries ?? []).map(row => ({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: row.alias, library: row })), 120 ].filter(row => matchesLibrary(row, search.value)); 121 for (const choice of choices) { 122 const button = document.createElement('button'); 123 button.type = 'button'; 124 button.dataset['manifest'] = choice.manifest; 125 const title = document.createElement('strong'); 126 title.textContent = choice.title + (choice.library ? ' · ' + choice.library.version : ''); 127 const description = document.createElement('span'); 128 description.textContent = choice.description; 129 const label = document.createElement('small'); 130 const match = choice.names.filter(n => search.value.trim() && n.toLowerCase().includes(search.value.toLowerCase().trim())).slice(0, 5); 131 label.textContent = `${choice.library ? 'Published library' : 'Local file'} · ${choice.names.length} exports${match.length ? ' · ' + match.join(', ') : ''}`; 132 button.append(title, description, label); 133 button.onclick = () => show(choice); 134 results.append(button); 135 } 136 if (!choices.length) 137 results.textContent = 'No matching libraries. Try an exported name or a broader search.'; 138 } 139 open.onclick = () => { 140 if (!options.available()) 141 return; 142 snapshot = options.source.value; 143 backend = options.backend(); 144 search.value = ''; 145 dialog.showModal(); 146 browse(); 147 search.focus(); 148 if (!libraries) { 149 message('Loading library references…'); 150 void fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152).then(bytes => { 151 libraries = libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))); 152 if (dialog.open && !selected) 153 browse(); 154 }).catch(error => { if (dialog.open) 155 message(`Library references could not be loaded: ${String(error)}. Close and reopen to retry. Local modules remain available.`); }); 156 } 157 }; 158 search.oninput = browse; 159 alias.oninput = check; 160 get('import-export-search').oninput = renderExports; 161 versions.onchange = () => { const row = libraries?.find(r => r.name === selected?.library?.name && r.version === versions.value); if (row) 162 show({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: alias.value, library: row }, false); }; 163 get('import-back').onclick = () => { browse(); search.focus(); }; 164 get('import-cancel').onclick = finish; 165 dialog.addEventListener('cancel', () => stop()); 166 dialog.addEventListener('close', () => { if (!dialog.open) 167 stop(); }); 168 get('copy-import').onclick = () => { void navigator.clipboard.writeText(statement()).then(() => message('Import copied.'), () => message('Select and copy the import preview.')); }; 169 insert.onclick = () => { 170 if (!plan || !options.available() || options.source.value !== snapshot || options.backend() !== backend) { 171 stop(); 172 insert.disabled = true; 173 message('The editor changed. Close and reopen the picker to use the current source.'); 174 return; 175 } 176 const checked = plan; 177 finish(); 178 options.source.focus(); 179 if (checked.existing) { 180 options.source.setSelectionRange(checked.existing.start, checked.existing.end); 181 return; 182 } 183 // Native textarea editing supplies one undo transaction on supported browsers. 184 options.source.setSelectionRange(0, 0); 185 if (!document.execCommand('insertText', false, checked.statement)) { 186 message('Your browser could not insert an undoable edit. Copy the import from the picker.'); 187 dialog.showModal(); 188 return; 189 } 190 options.source.setSelectionRange(0, checked.statement.length); 191 }; 192 return { cancel: () => { if (dialog.open) 193 finish(); } }; 194 }