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