diff --git a/lib/menu.css b/lib/menu.css
new file mode 100644
index 0000000..8c5c8c5
--- /dev/null
+++ b/lib/menu.css
@@ -0,0 +1,122 @@
+ /* Navbar container */
+.igv-darkbar {
+ overflow: hidden;
+ background-color: #5f5f5f;
+ font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
+ height: 48px;
+}
+
+/* Links inside the navbar */
+.igv-navbar a:link {
+ float: left;
+ font-size: 16px;
+ color: white;
+ text-align: center;
+ padding: 12px 16px 16px 16px;
+ text-decoration: none;
+}
+
+.igv-navbar a:visited,
+.igv-navbar a:active {
+ color: white;
+}
+
+/* The dropdown container */
+.igv-dropdown {
+ float: left;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+/* Dropdown button */
+.igv-dropdown .igv-dropdown-button {
+ font-size: 16px;
+ border: none;
+ outline: none;
+ color: white;
+ padding: 14px 16px;
+ background-color: inherit;
+ cursor: pointer;
+ font-family: inherit;
+ margin: 0;
+}
+
+.igv-navbar i {
+ margin-left: 5px;
+}
+
+.igv-navbar a:hover,
+.igv-dropdown:hover .igv-dropdown-button {
+ color: #dfdfdf;
+}
+
+/* Dropdown content (hidden by default) */
+.igv-dropdown-content {
+ display: none;
+ position: absolute;
+ background-color: #f9f9f9;
+ min-width: 160px;
+ box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
+ z-index: 200;
+ top: 48px;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+/* Links inside the dropdown */
+.igv-dropdown-content a:link {
+ float: none;
+ color: #5f5f5f;
+ padding: 4px 12px;
+ text-decoration: none;
+ display: block;
+ text-align: left;
+}
+
+.igv-dropdown-content a:visited,
+.igv-dropdown-content a:active {
+ color: #5f5f5f;
+}
+
+/* Add a grey background color to dropdown links on hover */
+.igv-dropdown-content a:hover {
+ background-color: #ddd;
+}
+
+/* Show the dropdown menu on hover */
+.igv-dropdown:hover .igv-dropdown-content {
+ display: block;
+}
+
+
+/* Dialog styles */
+
+.igv-dialog {
+ display: none;
+ position: absolute;
+ top: calc(50% - 100px);
+ width: 50%;
+ min-width: 400px;
+ max-width: 1000px;
+}
+
+.igv-dialog label {
+ width: 100px;
+ display: inline-block;
+ font-weight: bold;
+}
+
+.igv-dialog input {
+ display: inline-block;
+ width: calc(100% - 120px);
+ margin-bottom: 5px;
+}
+
+.igv-dialog-footer {
+ margin-top: 10px;
+ text-align: right;
+}
+
+.igv-dialog-footer > button {
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/lib/menu.js b/lib/menu.js
new file mode 100644
index 0000000..ed974f9
--- /dev/null
+++ b/lib/menu.js
@@ -0,0 +1,320 @@
+import './menu.css';
+import igv from "./igv";
+import igv_jupyter from "../package.json";
+
+class Menu {
+ /**
+ * Initialize unrendered igv-jupyter menus
+ */
+ static init() {
+ document.querySelectorAll('.igv-navbar').forEach((navbar) => {
+ if (!navbar.classList.contains('igv-navbar-rendered')) Menu.render(navbar);
+ });
+ }
+
+ /**
+ * Initialize a specific igv-jupyter menu
+ *
+ * @param div - menu node to initialize
+ */
+ static render(navbar) {
+ navbar.classList.add('igv-darkbar');
+ navbar.appendChild(Menu.create_menu_dropdown('Genome', [
+ Menu.create_menu_item('Local File ...', GenomeMenu.local_genome),
+ Menu.create_menu_item('URL ...', GenomeMenu.remote_genome)
+ // TODO: List genomes
+ ]));
+ navbar.appendChild(Menu.create_menu_dropdown('Tracks', [
+ Menu.create_menu_item('Local File ...', TrackMenu.local_track),
+ Menu.create_menu_item('URL ...', TrackMenu.remote_track)
+ // TODO: List tracks
+ ]));
+ navbar.appendChild(Menu.create_menu_dropdown('Session', [
+ Menu.create_menu_item('Local File ...', SessionMenu.local_session),
+ Menu.create_menu_item('URL ...', SessionMenu.remote_session),
+ Menu.create_menu_item('Save', SessionMenu.save_session)
+ ]));
+ // navbar.appendChild(Menu.create_menu_item('Share', null)); // Doesn't make sense in JupyterLab
+ // navbar.appendChild(Menu.create_menu_item('Bookmark', null)); // Doesn't make sense in JupyterLab
+ navbar.appendChild(Menu.create_menu_item('Save SVG', SVGMenu.create_svg));
+ navbar.appendChild(Menu.create_menu_dropdown('Help', [
+ Menu.create_menu_item('GitHub Repository', HelpMenu.github),
+ Menu.create_menu_item('User Forum', HelpMenu.forum),
+ Menu.create_menu_item('About IGV-Jupyter', HelpMenu.about)
+ ]));
+
+ navbar.classList.add('igv-menu-rendered');
+ navbar.parentNode.style.overflow = 'visible';
+ Menu.populate_genomes(navbar);
+ }
+
+ static populate_genomes(navbar) {
+ fetch('https://s3.amazonaws.com/igv.org.genomes/genomes.json', { mode: 'cors' })
+ .then(response => response.json())
+ .then(data => {
+ const menu = navbar.querySelector(".igv-dropdown[data-name='Genome'] > .igv-dropdown-content");
+ menu.appendChild(document.createElement('hr'));
+ for (const genome of data) {
+ menu.appendChild(Menu.create_menu_item(genome.name, (igv_instance) => {
+ igv_instance.loadGenome(genome);
+ }));
+ }
+ });
+ }
+
+ static create_menu_item(name, callback) {
+ const link = document.createElement('a');
+ link.href = '#';
+ link.textContent = name;
+ link.addEventListener('click', (event) => {
+ callback(Menu.igv_from_event(event));
+ });
+ return link
+ }
+
+ static create_menu_dropdown(name, items) {
+ const dropdown = document.createElement('div');
+ dropdown.setAttribute('data-name', name);
+ dropdown.classList.add('igv-dropdown');
+ dropdown.appendChild(Menu.menu_button(name));
+ dropdown.appendChild(Menu.menu_content(items));
+ return dropdown;
+ }
+
+ static menu_content(items) {
+ const content = document.createElement('div');
+ content.classList.add('igv-dropdown-content');
+ for (const item of items) content.appendChild(item);
+ return content
+ }
+
+ static menu_button(name) {
+ const button = document.createElement('button');
+ button.classList.add('igv-dropdown-button');
+ button.textContent = name;
+ button.appendChild(Menu.create_caret());
+ return button;
+ }
+
+ static create_caret() {
+ const icon = document.createElement('i');
+ icon.classList.add('fa', 'fa-caret-down');
+ return icon;
+ }
+
+ static igv_from_event(event) {
+ const igv_node = event.target.closest('.igv-navbar').parentNode.lastChild;
+ return igv.browserCache[igv_node.id];
+ }
+}
+
+class Dialog {
+ static form_data(element) {
+ const data = {};
+ element.querySelectorAll('input, select, textarea').forEach((e) => {
+ const name = e.getAttribute('name');
+ data[name] = e.value;
+ });
+ return data;
+ }
+
+ static dialog_footer(element, resolve, reject) {
+ // Create the dialog footer
+ const footer = document.createElement('footer');
+ footer.classList.add('igv-dialog-footer');
+
+ // Create the Cancel button
+ const cancel_button = document.createElement('button');
+ cancel_button.classList.add('jp-Dialog-button', 'jp-mod-styled', 'jp-mod-reject');
+ cancel_button.innerText = 'Cancel';
+ cancel_button.addEventListener('click', () => {
+ element.remove();
+ reject();
+ });
+ footer.append(cancel_button);
+
+ // Create the OK button
+ const ok_button = document.createElement('button');
+ ok_button.classList.add('jp-Dialog-button', 'jp-mod-styled', 'jp-mod-accept');
+ ok_button.innerText = 'OK';
+ ok_button.addEventListener('click', () => {
+ const data = Dialog.form_data(element);
+ element.remove();
+ resolve(data);
+ });
+ footer.append(ok_button);
+
+ return footer;
+ }
+
+ static create(content) {
+ const element = document.createElement('dialog');
+ element.classList.add('igv-dialog');
+ element.innerHTML = content;
+ const button_promse = new Promise((resolve, reject) => {
+ element.append(Dialog.dialog_footer(element, resolve, reject));
+ });
+ document.body.append(element);
+ element.style.display = 'block';
+ return button_promse;
+ }
+
+ static name_from_url(url) {
+ const name = url.substring(url.lastIndexOf('/')+1);
+ if (!name.trim().length) return url;
+ else return name;
+ }
+
+ static upload() {
+ let input = document.querySelector('.igv-upload-input');
+ if (!input) { // Lazily create the upload input, if necessary
+ input = document.createElement('input');
+ input.setAttribute('type', 'file');
+ input.classList.add('igv-upload-input');
+ input.style.display = 'none';
+ document.body.appendChild(input);
+ }
+ return input;
+ }
+
+ static read_local_file(accept=null, data_uri=false) {
+ return new Promise((resolve, reject) => {
+ const upload_input = Dialog.upload();
+ if (accept) upload_input.setAttribute('accept', accept);
+ else upload_input.removeAttribute('accept');
+ upload_input.addEventListener('change', () => {
+ if (upload_input.files.length === 0) return; // Protect against empty input
+ const reader = new FileReader();
+ reader.onload = event => {
+ resolve({
+ 'name': upload_input.files[0].name,
+ 'contents': event.target.result
+ });
+ };
+ if (data_uri) reader.readAsDataURL(upload_input.files[0]);
+ else reader.readAsText(upload_input.files[0]);
+ }, { 'once': true });
+ upload_input.click();
+ });
+ }
+}
+
+class GenomeMenu {
+ static local_genome(igv_instance) {
+ Dialog.read_local_file(null, true)
+ .then((data) => {
+ igv_instance.loadGenome({
+ id: data.name,
+ fastaURL: data.contents,
+ indexed: false
+ }).catch(error => {
+ alert("Genome did not load - invalid data and/or index file(s). " + error);
+ })
+ });
+ }
+
+ static remote_genome(igv_instance) {
+ Dialog.create(`
+
+
+
+
+ `).then((data) => {
+ igv_instance.loadGenome({
+ id: Dialog.name_from_url(data.genome_url),
+ fastaURL: data.genome_url,
+ indexURL: data.index_url
+ });
+ });
+ }
+}
+
+class TrackMenu {
+ static local_track(igv_instance) {
+ Dialog.read_local_file(null, true)
+ .then((data) => {
+ igv_instance.loadTrack({
+ name: data.name,
+ url: data.contents,
+ format: TrackMenu.guess_format(data.name)
+ });
+ });
+ }
+
+ static remote_track(igv_instance) {
+ Dialog.create(`
+
+
+
+
+ `).then((data) => {
+ igv_instance.loadTrack({
+ name: Dialog.name_from_url(data.track_url),
+ url: data.track_url,
+ indexURL: data.index_url
+ });
+ });
+ }
+
+ static guess_format(filename) {
+ const type = filename.substring(filename.lastIndexOf('.')+1);
+ if (!type.trim().length) return filename;
+ else return type;
+ }
+}
+
+class SessionMenu {
+ static local_session(igv_instance) {
+ Dialog.read_local_file('.json')
+ .then(data => igv_instance.loadSession(JSON.parse(data.contents)));
+ }
+
+ static remote_session(igv_instance) {
+ Dialog.create(`
+
+
+ `).then((data) => {
+ igv_instance.loadSession({
+ url: data.session_url
+ });
+ });
+ }
+
+ static save_session(igv_instance) {
+ Dialog.create(`
+
+
+ `).then((data) => {
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(igv_instance.toJSON()));
+ if (!data.filename.endsWith('.json')) data.filename += '.json';
+ const downloadAnchorNode = document.createElement('a');
+ downloadAnchorNode.setAttribute("href", dataStr);
+ downloadAnchorNode.setAttribute("download", data.filename);
+ document.body.appendChild(downloadAnchorNode); // required for firefox
+ downloadAnchorNode.click();
+ downloadAnchorNode.remove();
+ });
+ }
+}
+
+class SVGMenu {
+ static create_svg(igv_instance) {
+ igv_instance.saveSVGtoFile('igv.svg');
+ }
+}
+
+class HelpMenu {
+ static github() {
+ window.open('https://github.com/igvteam/igv-jupyter');
+ }
+
+ static forum() {
+ window.open('https://groups.google.com/g/igv-help');
+ }
+
+ static about() {
+ alert(`igv-jupyter Version ${igv_jupyter.version}\nigv.js Version ${igv.version()}`);
+ }
+}
+
+export default Menu;
\ No newline at end of file