/** * notion-enhancer: icon sets * (c) 2019 jayhxmo (https://jaymo.io/) * (c) 2020 CloudHill <rl.cloudhill@gmail.com> (https://github.com/CloudHill) * (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/) * (https://notion-enhancer.github.io/) under the MIT license */ const getImgData = (url) => new Promise(async (res, rej) => { const blob = await fetch(url).then((res) => res.blob()), reader = new FileReader(); reader.onload = (e) => res(e.target.result); reader.readAsDataURL(blob); }); export default async function ({ web, fs, components, notion }, db) { const recentUploads = await db.get(['recent_uploads'], []), preventQualityReduction = await db.get(['prevent_quality_reduction']), $triangleSvg = web.html`<svg viewBox="0 0 100 100" class="triangle"> <polygon points="5.9,88.2 50,11.8 94.1,88.2" /> </svg>`; const customIconSets = [], customIconsFile = await db.get(['json']); if (customIconsFile?.content) { const iconsData = JSON.parse(customIconsFile.content); customIconSets.push(...(iconsData.icons || iconsData)); } const enhancerIconSets = [], enhancerIconsUrl = 'https://raw.githubusercontent.com/notion-enhancer/icons/main/'; if (await db.get(['default_sets'])) { const iconsData = await fs.getJSON(`${enhancerIconsUrl}/icons.json`); enhancerIconSets.push(...(iconsData.icons || iconsData)); } const mediaMenuSelector = '.notion-media-menu', mediaScrollerSelector = '.notion-media-menu > .notion-scroller', mediaFilterSelector = '.notion-media-menu > :first-child > :last-child', mediaLinkInputSelector = '.notion-focusable-within > input[type=url]', tabBtnSelector = (n) => `.notion-media-menu > :first-child > :first-child > :nth-child(${n})`; const renderSetTitle = async (title, loadPromises = [], $tooltip = undefined) => { const isCollapsed = await db.get(['collapsed', title], false), $title = web.html`<p class="icon_sets--title" ${isCollapsed ? 'data-collapsed="true"' : ''}></p>`, $spinner = web.html`<span class="icon_sets--spinner"> <img src="/images/loading-spinner.4dc19970.svg" /> </span>`; web.render( $title, $triangleSvg.cloneNode(true), web.html`<span>${title}</span>`, $spinner ); $title.addEventListener('click', () => { const newState = $title.dataset.collapsed !== 'true'; db.set(['collapsed', title], newState); $title.dataset.collapsed = newState; }); // hide spinner after all icons finish loading // doesn't need to be waited on by renderers (async () => { await Promise.all(loadPromises); $spinner.remove(); if ($tooltip) { const $infoSvg = web.html`${await components.feather('info', { class: 'info' })}`; components.addTooltip($infoSvg, $tooltip, { offsetDirection: 'right' }); web.render($title, $infoSvg); } })(); return $title; }; const $iconsTab = web.html`<div class="icon_sets--tab_button"> <div class="notion-focusable" role="button" tabindex="0">Icons</div> </div>`, // actions $iconsLinkInput = web.html`<div class="icon_sets--link_input"> <input placeholder="Paste an image linkā¦" type="url"> </div>`, $iconsLinkSubmit = web.html`<button class="icon_sets--link_submit">Submit</button>`, $iconsUploadFile = web.html`<input type="file" accept="image/*" style="display:none">`, $iconsUploadSubmit = web.render( web.html`<button class="icon_sets--upload"></button>`, 'Upload an image', $iconsUploadFile ), // sets $setsList = web.html`<div class="icon_sets--list"></div>`, // container $iconsView = web.render( web.html`<div class="icon_sets--view" style="display:none"></div>`, web.render( web.html`<div class="icon_sets--actions"></div>`, web.render( web.html`<div class="notion-focusable-within" style="display:flex;border-radius:3px;"></div>`, $iconsLinkInput, $iconsLinkSubmit ), $iconsUploadSubmit ), web.render($setsList) ); let $mediaMenu, $activeTabUnderline; const insertIconsTab = async () => { if (document.contains($mediaMenu)) return; // prevent injection into file upload menus $mediaMenu = document.querySelector(mediaMenuSelector); if (!$mediaMenu || !$mediaMenu.textContent.includes('Emoji')) return; const $emojiTab = document.querySelector(tabBtnSelector(1)), $emojiScroller = document.querySelector(mediaScrollerSelector), $emojiFilter = document.querySelector(mediaFilterSelector), $uploadTab = document.querySelector(tabBtnSelector(2)), $linkTab = document.querySelector(tabBtnSelector(3)); $uploadTab.style.display = 'none'; $linkTab.style.display = 'none'; if ($activeTabUnderline) $activeTabUnderline.remove(); $activeTabUnderline = $emojiTab.children[1] || $uploadTab.children[1] || $linkTab.children[1]; $emojiTab.after($iconsTab); $emojiScroller.after($iconsView); const renderRecentUploads = async () => { const $recentUploads = web.html`<div class="icon_sets--set"></div>`, loadPromises = []; for (let i = recentUploads.length - 1; i >= 0; i--) { const { signed, url } = recentUploads[i], $icon = web.html`<span class="icon_sets--icon"> <img src="${web.escape(signed)}"> </span>`; web.render($recentUploads, $icon); $icon.addEventListener('click', (event) => { if (event.shiftKey) { recentUploads.splice(i, 1); db.set(['recent_uploads'], recentUploads); $icon.remove(); } else setIcon({ signed, url }); }); loadPromises.push( new Promise((res, rej) => { $icon.firstElementChild.onload = res; $icon.firstElementChild.onerror = res; }) ); } const $recentUploadsTitle = await renderSetTitle( 'Recent', loadPromises, web.html`<p><b>Click</b> to reuse an icon <br><b>Shift-click</b> to remove it</p>` ); web.render($setsList, $recentUploadsTitle, $recentUploads); }, renderIconSet = async (iconData, enhancerSet = false) => { try { const $set = web.html`<div class="icon_sets--set"></div>`; if (iconData.sourceUrl?.endsWith?.('/')) { iconData.sourceUrl = iconData.sourceUrl.slice(0, -1); } const loadPromises = []; for (let i = 0; i < (iconData.count || iconData.source.length); i++) { const iconUrl = iconData.sourceUrl ? Array.isArray(iconData.source) ? `${iconData.sourceUrl}/${iconData.source[i]}.${iconData.extension}` : `${iconData.sourceUrl}/${iconData.source}_${i}.${iconData.extension}` : iconData.source[i], sprite = enhancerSet ? `style=" background-image: url(${enhancerIconsUrl}${iconData.source}/sprite.png); background-position: 0 -${i * 24}px; "` : '', $img = sprite ? web.html`<div class="icon_sets--sprite" ${sprite}></div>` : web.html`<img src="${web.escape(iconUrl)}">`, $icon = web.render(web.html`<span class="icon_sets--icon"></span>`, $img); web.render($set, $icon); $icon.addEventListener('click', (event) => { if (!event.shiftKey) setIcon({ signed: iconUrl, url: iconUrl }); }); if (!sprite) { loadPromises.push( new Promise((res, rej) => { $img.onload = res; $img.onerror = res; }) ); } } const author = iconData.author ? iconData.authorUrl ? web.raw`by <a target="_blank" href="${web.escape(iconData.authorUrl)}"> ${iconData.author} </a>` : web.raw`by ${web.escape(iconData.author)}` : '', $title = await renderSetTitle( `${web.escape(iconData.name)} ${author}`, loadPromises ); web.render($setsList, $title, $set); } catch (err) { console.error(err); web.render( $setsList, web.html`<div class="icon_sets--error"> Invalid set: ${web.escape(iconData?.name || 'Unknown')} </div>` ); } }, renderCustomIconSets = async () => { if (customIconSets.length) { web.render($setsList, web.html`<div class="icon_sets--divider"></div>`); } await Promise.all(customIconSets.map((set) => renderIconSet(set))); }, renderEnhancerIconSets = async () => { if (enhancerIconSets.length) { web.render($setsList, web.html`<div class="icon_sets--divider"></div>`); } await Promise.all( enhancerIconSets.map((set) => { set.sourceUrl = set.sourceUrl || enhancerIconsUrl + set.source; return renderIconSet(set, true); }) ); }; const displayIconsTab = async (force = false) => { if ($iconsTab.contains($activeTabUnderline) && !force) return; web.render($iconsTab, $activeTabUnderline); $iconsView.style.display = ''; $emojiScroller.style.display = 'none'; $emojiFilter.style.display = 'none'; web.empty($setsList); await renderRecentUploads(); await renderCustomIconSets(); await renderEnhancerIconSets(); $iconsView.querySelectorAll('.icon_sets--set').forEach(($set) => { $set.style.height = `${$set.scrollHeight}px`; }); }, displayEmojiTab = (force = false) => { if ($emojiTab.contains($activeTabUnderline) && !force) return; web.render($emojiTab, $activeTabUnderline); $iconsView.style.display = 'none'; $emojiScroller.style.display = ''; $emojiFilter.style.display = ''; }; // use onclick instead of eventlistener to override prev $iconsTab.onclick = displayIconsTab; $emojiTab.onclick = displayEmojiTab; // otherwise both may be visible on reopen displayEmojiTab(true); async function setIcon({ signed, url }) { // without this react gets upset displayEmojiTab(); $linkTab.firstChild.click(); await new Promise(requestAnimationFrame); $mediaMenu.parentElement.style.opacity = '0'; const iconUrl = preventQualityReduction ? await getImgData(signed) : url; // call native setter, imitate human input const $notionLinkInput = $mediaMenu.querySelector(mediaLinkInputSelector), proto = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value'); proto.set.call($notionLinkInput, iconUrl); const inputEvent = new Event('input', { bubbles: true }), enterKeydownEvent = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, keyCode: 13, }); $notionLinkInput.dispatchEvent(inputEvent); $notionLinkInput.dispatchEvent(enterKeydownEvent); } const submitLinkIcon = () => { const url = $iconsLinkInput.firstElementChild.value; if (!url) return; const icon = { signed: notion.sign(url, notion.getPageID()), url: url }; setIcon(icon); recentUploads.push(icon); db.set(['recent_uploads'], recentUploads); }; $iconsLinkInput.onkeyup = (event) => { if (event.code === 13) submitLinkIcon(); }; $iconsLinkSubmit.onclick = submitLinkIcon; // upload file to aws, then submit link $iconsUploadSubmit.onclick = () => $iconsUploadFile.click(); $iconsUploadFile.onchange = async (event) => { const file = event.target.files[0], url = await notion.upload(file), icon = { signed: notion.sign(url, notion.getPageID()), url: url }; setIcon(icon); recentUploads.push(icon); db.set(['recent_uploads'], recentUploads); }; }; web.addDocumentObserver(insertIconsTab, [mediaMenuSelector]); }