notion-enhancer/repo/icon-sets/client.mjs

314 lines
12 KiB
JavaScript

/**
* 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]);
}