/* * notion-enhancer core: menu * (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/) * (https://notion-enhancer.github.io/) under the MIT license */ 'use strict'; const _id = 'a6621988-551d-495a-97d8-3c568bca2e9e'; import { env, storage, web, fmt, fs, registry } from '../../helpers.js'; for (let mod of await registry.get( async (mod) => (await registry.enabled(mod.id)) && (!mod.environments || mod.environments.includes(env.name)) )) { for (let sheet of mod.css?.menu || []) { web.loadStyleset(`repo/${mod._dir}/${sheet}`); } } document .querySelector('img[data-view-target="notion"]') .addEventListener('click', env.focusNotion); web.hotkeyListener(await storage.get(_id, 'hotkey.focustoggle'), env.focusNotion); const tooltips = { $el: document.querySelector('.tooltip'), add($parent, selector, text) { text = fmt.md.render(text); $parent.addEventListener('mouseover', (event) => { if (event.target.matches(selector) || event.target.matches(`${selector} *`)) { this.$el.innerHTML = text; this.$el.style.display = 'block'; } }); $parent.addEventListener('mousemove', (event) => { this.$el.style.top = event.clientY - this.$el.clientHeight + 'px'; this.$el.style.left = event.clientX < window.innerWidth / 2 ? event.clientX + 20 + 'px' : ''; }); $parent.addEventListener('mouseout', (event) => { if (event.target.matches(selector) || event.target.matches(`${selector} *`)) { this.$el.style.display = ''; } }); }, }; const components = {}; components.card = { preview: ({ preview = '' }) => preview ? web.createElement(web.html`<img alt="" class="library--preview" src="${web.escapeHtml(preview)}" />`) : '', async name({ name, id, version, tags }) { if (registry.CORE.includes(id)) return web.createElement(web.html`<div class="library--title"><h2> <span> ${web.escapeHtml(name)} <span class="library--version">v${web.escapeHtml(version)}</span> </span> </h2></div>`); const $el = web.createElement(web.html`<label for="enable--${web.escapeHtml(id)}" class="library--title library--toggle_label" > <input type="checkbox" id="enable--${web.escapeHtml(id)}" ${(await storage.get('_enabled', id, false)) ? 'checked' : ''}/> <h2> <span> ${web.escapeHtml(name)} <span class="library--version">v${web.escapeHtml(version)}</span> </span> <span class="library--toggle"></span> </h2> </label>`); $el.addEventListener('change', async (event) => { storage.set('_enabled', id, event.target.checked); if ( event.target.checked && tags.includes('theme') && (await storage.get(_id, 'themes.autoresolve', true)) ) { const themes = await registry.get( (mod) => mod.tags.includes('theme') && mod.id !== id && ((mod.tags.includes('dark') && tags.includes('dark')) || (mod.tags.includes('light') && tags.includes('light'))) ); for (const theme of themes) { if (document.body.dataset.view === 'library') { const $toggle = document.getElementById(`enable--${theme.id}`); if ($toggle.checked) $toggle.click(); } else storage.set('_enabled', theme.id, false); } } }); return $el; }, tags: ({ tags = [] }) => web.createElement(web.html`<ul class="library--tags"> ${tags.map((tag) => web.html`<li>#${web.escapeHtml(tag)}</li>`).join('')} </ul>`), description: ({ description }) => web.createElement( web.html`<p class="library--description markdown">${fmt.md.renderInline( description )}</p>` ), authors: ({ authors }) => web.createElement(web.html`<ul class="library--authors"> ${authors .map( (author) => web.html`<li> <a href="${web.escapeHtml(author.url)}"> <img alt="" src="${web.escapeHtml(author.icon)}" /> <span>${web.escapeHtml(author.name)}</span> </a> </li>` ) .join('')} </ul>`), expand: async ({ id }) => web.createElement( web.html`<p class="library--expand"> <a href="?view=mod&id=${web.escapeHtml(id)}"> <span><i data-icon="fa/long-arrow-alt-right"></i></span> <span>settings & documentation</span> </a> </p>` ), async _generate(mod) { const card = web.createElement(web.html`<article class="library--card"></article>`), body = web.createElement(web.html`<div></div>`); card.append(this.preview(mod)); body.append(await this.name(mod)); body.append(this.tags(mod)); body.append(this.description(mod)); body.append(this.authors(mod)); body.append(await this.expand(mod)); card.append(body); return card; }, }; components.options = { async toggle(id, { key, label, tooltip }) { const state = await storage.get(id, key), opt = web.createElement(web.html`<label for="toggle--${web.escapeHtml(`${id}.${key}`)}" class="library--toggle_label" > <input type="checkbox" id="toggle--${web.escapeHtml(`${id}.${key}`)}" ${state ? 'checked' : ''}/> <p> <span data-tooltip>${web.escapeHtml(label)} ${tooltip ? web.html`<i data-icon="fa/question-circle"></i>` : ''}</span> <span class="library--toggle"></span> </p> </label>`); opt.addEventListener('change', (event) => storage.set(id, key, event.target.checked)); if (tooltip) tooltips.add(opt, '[data-tooltip]', tooltip); return opt; }, async select(id, { key, label, tooltip, values }) { const state = await storage.get(id, key), opt = web.createElement(web.html`<label for="select--${web.escapeHtml(`${id}.${key}`)}" class="library--select_label" > <p><span data-tooltip>${web.escapeHtml(label)} ${tooltip ? web.html`<i data-icon="fa/question-circle"></i>` : ''}</span></p> <p class="library--select"> <span><i data-icon="fa/caret-down"></i></span> <select id="select--${web.escapeHtml(`${id}.${key}`)}"> ${values.map( (value) => web.html`<option value="${web.escapeHtml(value)}" ${value === state ? 'selected' : ''}> ${web.escapeHtml(value)}</option>` )} </select> </p> </label>`); opt.addEventListener('change', (event) => storage.set(id, key, event.target.value)); if (tooltip) tooltips.add(opt, '[data-tooltip]', tooltip); return opt; }, async text(id, { key, label, tooltip }) { const state = await storage.get(id, key), opt = web.createElement(web.html`<label for="text--${web.escapeHtml(`${id}.${key}`)}" class="library--text_label" > <p><span data-tooltip>${web.escapeHtml(label)} ${tooltip ? web.html`<i data-icon="fa/question-circle"></i>` : ''}</span></p> <textarea id="text--${web.escapeHtml(`${id}.${key}`)}" rows="1">${web.escapeHtml(state)}</textarea> </label>`); opt.querySelector('textarea').addEventListener('input', (event) => { event.target.style.removeProperty('--txt--scroll-height'); event.target.style.setProperty( '--txt--scroll-height', event.target.scrollHeight + 1 + 'px' ); }); opt.addEventListener('change', (event) => storage.set(id, key, event.target.value)); if (tooltip) tooltips.add(opt, '[data-tooltip]', tooltip); return opt; }, async number(id, { key, label, tooltip }) { const state = await storage.get(id, key), opt = web.createElement(web.html`<label for="number--${web.escapeHtml(`${id}.${key}`)}" class="library--number_label" > <p><span data-tooltip>${web.escapeHtml(label)} ${tooltip ? web.html`<i data-icon="fa/question-circle"></i>` : ''}</span></p> <input id="number--${web.escapeHtml(`${id}.${key}`)}" type="number" value="${web.escapeHtml(state.toString())}"/> </label>`); opt.addEventListener('change', (event) => storage.set(id, key, event.target.value)); if (tooltip) tooltips.add(opt, '[data-tooltip]', tooltip); return opt; }, async file(id, { key, label, tooltip, extensions }) { const state = await storage.get(id, key), opt = web.createElement(web.html`<label for="file--${web.escapeHtml(`${id}.${key}`)}" class="library--file_label" > <input type="file" id="file--${web.escapeHtml(`${id}.${key}`)}" ${web.escapeHtml( extensions && extensions.length ? ` accept=${web.escapeHtml(extensions.join(','))}` : '' )} /> <p><span data-tooltip>${web.escapeHtml(label)} <i data-icon="fa/question-circle"></i></span></p> <p class="library--file"> <span><i data-icon="fa/file"></i></span> <span class="library--file_path">${web.escapeHtml(state || 'choose file...')}</span> </p> </label>`); opt.addEventListener('change', (event) => { const file = event.target.files[0], reader = new FileReader(); opt.querySelector('.library--file_path').innerText = file.name; storage.set(id, key, file.name); reader.onload = (progress) => { storage.set(id, `_file.${key}`, progress.currentTarget.result); }; reader.readAsText(file); }); opt.addEventListener('click', (event) => { document.documentElement.scrollTop = 0; }); tooltips.add( opt, '[data-tooltip]', `${ tooltip ? `${tooltip}\n\n` : '' }**warning:** browser extensions do not have true filesystem access, so file content is only saved on selection. re-select files to apply edits.` ); return opt; }, async _generate(mod) { const card = await components.card._generate(mod); card.querySelector('.library--expand').remove(); if (mod.options && mod.options.length) { const options = web.createElement(web.html`<div class="library--options"></div>`), inputs = await Promise.all( mod.options .filter((opt) => !opt.environments || opt.environments.includes(env.name)) .map((opt) => this[opt.type](mod.id, opt)) ); inputs.forEach((opt) => options.append(opt)); card.append(options); } return card; }, }; components.documentation = { _reloadTriggered: false, buttons({ _dir }) { const $el = web.createElement(web.html`<p class="documentation--buttons"> <a href="?view=library"> <span><i data-icon="fa/long-arrow-alt-left"></i></span> <span>back to library</span> </a> <a href="https://github.com/notion-enhancer/extension/tree/main/repo/${encodeURIComponent( _dir )}" > <span><i data-icon="fa/code"></i></span> <span>view source code</span> </a> <button class="documentation--reload"${this._reloadTriggered ? ' data-triggered' : ''}> <span><i data-icon="fa/redo"></i></span> <span>reload tabs to apply changes</span> </button> </p>`); storage.onChange(() => { const $reload = $el.querySelector('.documentation--reload'); if (document.body.contains($el) && !$reload.dataset.triggered) { $reload.dataset.triggered = true; this._reloadTriggered = true; } }); $el.querySelector('.documentation--reload').addEventListener('click', env.reloadTabs); return $el; }, readme: async (mod) => { const readme = web.createElement(web.html`<article class="documentation--body markdown"> ${ (await fs.isFile(`repo/${mod._dir}/README.md`)) ? fmt.md.render(await fs.getText(`repo/${mod._dir}/README.md`)) : '' } </article>`); fmt.Prism.highlightAllUnder(readme); return readme; }, }; const views = { $container: document.querySelector('main'), _router(event) { event.preventDefault(); let anchor, i = 0; do { anchor = event.path[i]; i++; } while (anchor.nodeName !== 'A'); if (location.search !== anchor.getAttribute('href')) { window.history.pushState( { search: anchor.getAttribute('href'), hash: '' }, '', anchor.href ); this._load(); } }, _navigator(event) { event.preventDefault(); const hash = event.target.getAttribute('href').slice(1); document.getElementById(hash).scrollIntoView(true); document.documentElement.scrollTop = 0; history.replaceState({ search: location.search, hash }, null, `#${hash}`); }, _reset() { document .querySelectorAll('a[href^="?"]') .forEach((a) => a.removeEventListener('click', this._router)); document .querySelectorAll('a[href^="#"]') .forEach((a) => a.removeEventListener('click', this._navigator)); this.$container.style.opacity = 0; return new Promise((res, rej) => { setTimeout(() => { this.$container.innerHTML = ''; this.$container.style.opacity = ''; document.body.dataset.view = ''; document .querySelector('[data-view-target][data-view-active]') ?.removeAttribute('data-view-active'); res(); }, 200); }); }, async _load() { await this._reset(); const search = new Map( location.search .slice(1) .split('&') .map((query) => query.split('=')) ); switch (search.get('view')) { case 'mod': const mod = (await registry.get()).find((mod) => mod.id === search.get('id')); if (mod) { await this.mod(mod); break; } case 'library': await this.library(); break; default: window.history.replaceState( { search: '?view=library', hash: '' }, null, '?view=library' ); return this._load(); } setTimeout(() => { document.getElementById(location.hash.slice(1))?.scrollIntoView(true); document.documentElement.scrollTop = 0; }, 50); document .querySelectorAll('img') .forEach((img) => (img.onerror = (event) => event.target.remove())); document .querySelectorAll('a[href^="?"]') .forEach((a) => a.addEventListener('click', this._router)); document .querySelectorAll('a[href^="#"]') .forEach((a) => a.addEventListener('click', this._navigator)); document.querySelectorAll('[data-icon]').forEach((icon) => fs.getText(`icons/${icon.dataset.icon}.svg`).then((svg) => { svg = web.createElement(svg); for (const attr of icon.attributes) { svg.setAttribute(attr.name, attr.value); } icon.replaceWith(svg); }) ); }, async mod(mod) { document.body.dataset.view = 'mod'; document.querySelector('header [data-view-target="library"]').dataset.active = true; this.$container.append(components.documentation.buttons(mod)); this.$container.append(await components.options._generate(mod)); this.$container.append(await components.documentation.readme(mod)); }, async library() { document.body.dataset.view = 'library'; document.querySelector('header [data-view-target="library"]').dataset.active = true; for (const mod of await registry.get( (mod) => !mod.environments || mod.environments.includes(env.name) )) { this.$container.append(await components.card._generate(mod)); } }, }; views._router = views._router.bind(views); views._navigator = views._navigator.bind(views); views._load(); window.addEventListener('popstate', (event) => { if (event.state) views._load(); }); const notifications = { $list: document.querySelector('.notification--list'), push({ heading, message = '', type = 'information' }, onDismiss = () => {}) { let svg = '', className = 'notification'; switch (type) { case 'celebration': svg = web.html`<i data-icon="monster/party"></i>`; className += ' celebration'; break; case 'information': svg = web.html`<i data-icon="fa/info"></i>`; className += ' information'; break; case 'warning': svg = web.html`<i data-icon="fa/exclamation-triangle"></i>`; className += ' warning'; break; } const $notif = web.createElement(web.html`<div role="alert" class="${className}"> <div>${svg}</div> <div> <h3>${web.escapeHtml(heading)}</h3> <p>${fmt.md.renderInline(message)}</p> </div> <button class="notification--dismiss">×</button> </div>`); $notif.querySelector('.notification--dismiss').addEventListener('click', (event) => { $notif.style.opacity = 0; $notif.style.transform = 'scaleY(0)'; $notif.style.marginTop = `-${ $notif.offsetHeight / parseFloat(getComputedStyle(document.documentElement).fontSize) }rem`; setTimeout(() => $notif.remove(), 400); onDismiss(); }); setTimeout(() => { $notif.style.opacity = 1; }, 100); return this.$list.append($notif); }, async fetch() { const notifications = { list: await fs.getJSON('https://notion-enhancer.github.io/notifications.json'), dismissed: await storage.get(_id, 'notifications', []), }; notifications.list = notifications.list.sort((a, b) => b.id - a.id); notifications.waiting = notifications.list.filter( ({ id }) => !notifications.dismissed.includes(id) ); for (let notification of notifications.waiting) { if ( notification.heading && notification.appears_on && (notification.appears_on.versions.includes('*') || notification.appears_on.versions.includes(env.version)) && notification.appears_on.extension ) { this.push(notification, async () => { const dismissed = await storage.get(_id, 'notifications', []); storage.set('_notifications', 'external', [ ...new Set([...dismissed, notification.id]), ]); }); } } }, }; for (const error of await registry.errors()) { notifications.push({ heading: `error: ${error.source}`, message: error.message, type: 'warning', }); } notifications.fetch(); async function theme() { document.documentElement.className = `notion-${ (await storage.get(_id, 'theme')) || 'dark' }-theme`; } window.addEventListener('focus', theme); theme(); // registry.errors().then((err) => { // document.querySelector('[data-section="alerts"]').innerHTML = JSON.stringify(err); // });