544 lines
16 KiB
JavaScript
544 lines
16 KiB
JavaScript
const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown';
|
|
const badgeEditorEndpoint = '/hyperapi/badge-editor';
|
|
const previewEndpoint = '/api/babycode-preview';
|
|
const userEndpoint = '/api/current-user';
|
|
|
|
const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))}
|
|
|
|
export default class {
|
|
async showBookmarkMenu(ev, el) {
|
|
if ((ev.sender.dataset.bookmarkId === el.prop('bookmarkId')) && el.childElementCount === 0) {
|
|
const searchParams = new URLSearchParams({
|
|
'id': ev.sender.dataset.conceptId,
|
|
'require_reload': el.dataset.requireReload,
|
|
});
|
|
const bookmarkMenuHref = `${bookmarkMenuHrefTemplate}/${ev.sender.dataset.bookmarkType}?${searchParams}`;
|
|
const res = await this.api.getHTML(bookmarkMenuHref);
|
|
if (res.error) {
|
|
return;
|
|
}
|
|
const frag = res.value;
|
|
el.appendChild(frag);
|
|
const menu = el.childNodes[0];
|
|
menu.showPopover();
|
|
|
|
const bRect = el.getBoundingClientRect();
|
|
const menuRect = menu.getBoundingClientRect();
|
|
const preferredLeft = bRect.right - menuRect.width;
|
|
const preferredRight = bRect.right;
|
|
const enoughSpace = preferredLeft >= 0;
|
|
const scrollY = window.scrollY || window.pageYOffset;
|
|
if (enoughSpace) {
|
|
menu.style.left = `${preferredLeft}px`;
|
|
} else {
|
|
menu.style.left = `${bRect.left}px`;
|
|
}
|
|
menu.style.top = `${bRect.bottom + scrollY}px`;
|
|
|
|
menu.addEventListener('beforetoggle', (e) => {
|
|
if (e.newState === 'closed') {
|
|
// if it's still in the tree, remove it
|
|
// the delay is required to make sure its removed instantly when
|
|
// clicking the button when the menu is open
|
|
setTimeout(() => {menu.remove()}, 100);
|
|
};
|
|
}, { once: true });
|
|
|
|
} else if (el.childElementCount > 0) {
|
|
el.removeChild(el.childNodes[0]);
|
|
}
|
|
}
|
|
|
|
selectBookmarkCollection(ev, el) {
|
|
const clicked = ev.sender;
|
|
|
|
if (ev.sender === el) {
|
|
if (clicked.classList.contains('selected')) {
|
|
clicked.classList.remove('selected');
|
|
} else {
|
|
clicked.classList.add('selected');
|
|
}
|
|
} else {
|
|
el.classList.remove('selected');
|
|
}
|
|
}
|
|
|
|
async saveBookmarks(ev, el) {
|
|
const bookmarkHref = el.prop('bookmarkEndpoint');
|
|
const collection = el.querySelector('.bookmark-dropdown-item.selected');
|
|
let data = {};
|
|
if (collection) {
|
|
data['operation'] = 'move';
|
|
data['collection_id'] = collection.dataset.collectionId;
|
|
data['memo'] = el.querySelector('.bookmark-memo-input').value;
|
|
} else {
|
|
data['operation'] = 'remove';
|
|
data['collection_id'] = el.prop('originallyContainedIn');
|
|
}
|
|
|
|
const options = {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}
|
|
const requireReload = el.propToInt('requireReload') !== 0;
|
|
el.remove();
|
|
await fetch(bookmarkHref, options);
|
|
if (requireReload) {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
async copyCode(ev, el) {
|
|
if (!el.isSender) {
|
|
return;
|
|
}
|
|
await navigator.clipboard.writeText(el.value);
|
|
el.textContent = 'Copied!'
|
|
await delay(1000);
|
|
el.textContent = 'Copy';
|
|
}
|
|
|
|
toggleAccordion(ev, el) {
|
|
const accordion = el;
|
|
const header = accordion.querySelector('.accordion-header');
|
|
if (!header.contains(ev.sender)){
|
|
return;
|
|
}
|
|
const btn = ev.sender;
|
|
const content = el.querySelector('.accordion-content');
|
|
// these are all meant to be in sync
|
|
accordion.classList.toggle('hidden');
|
|
content.classList.toggle('hidden');
|
|
btn.textContent = accordion.classList.contains('hidden') ? '+' : '-';
|
|
}
|
|
|
|
toggleTab(ev, el) {
|
|
const tabButtonsContainer = el.querySelector('.tab-buttons');
|
|
if (!el.contains(ev.sender)) {
|
|
return;
|
|
}
|
|
|
|
if (ev.sender.classList.contains('active')) {
|
|
return;
|
|
}
|
|
|
|
const targetId = ev.sender.prop('targetId');
|
|
const contents = el.querySelectorAll('.tab-content');
|
|
for (let content of contents) {
|
|
if (content.id === targetId) {
|
|
content.classList.add('active');
|
|
} else {
|
|
content.classList.remove('active');
|
|
}
|
|
}
|
|
for (let button of tabButtonsContainer.children) {
|
|
if (button.dataset.targetId === targetId) {
|
|
button.classList.add('active');
|
|
} else {
|
|
button.classList.remove('active');
|
|
}
|
|
}
|
|
}
|
|
|
|
#previousMarkup = null;
|
|
async babycodePreview(ev, el) {
|
|
if (ev.sender.classList.contains('active')) {
|
|
return;
|
|
}
|
|
|
|
const previewErrorsContainer = el.querySelector('#babycode-preview-errors-container');
|
|
const previewContainer = el.querySelector('#babycode-preview-container');
|
|
const ta = document.getElementById('babycode-content');
|
|
const markup = ta.value.trim();
|
|
if (markup === '') {
|
|
previewErrorsContainer.textContent = 'Type something!';
|
|
previewContainer.textContent = '';
|
|
this.#previousMarkup = '';
|
|
return;
|
|
}
|
|
|
|
if (markup === this.#previousMarkup) {
|
|
return;
|
|
}
|
|
|
|
const bannedTags = JSON.parse(document.getElementById('babycode-banned-tags').value);
|
|
this.#previousMarkup = markup;
|
|
|
|
const res = await this.api.getJSON(previewEndpoint, [], {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
markup: markup,
|
|
banned_tags: bannedTags,
|
|
}),
|
|
});
|
|
if (res.error) {
|
|
switch (res.error.status) {
|
|
case 429:
|
|
previewErrorsContainer.textContent = '(Old preview, try again in a few seconds.)'
|
|
this.#previousMarkup = '';
|
|
break;
|
|
case 400:
|
|
previewErrorsContainer.textContent = '(Request got malformed.)'
|
|
break;
|
|
case 401:
|
|
previewErrorsContainer.textContent = '(You are not logged in.)'
|
|
break;
|
|
default:
|
|
previewErrorsContainer.textContent = '(Error. Check console.)'
|
|
break;
|
|
}
|
|
} else {
|
|
previewErrorsContainer.textContent = '';
|
|
previewContainer.innerHTML = res.value.html;
|
|
}
|
|
}
|
|
|
|
insertBabycodeTag(ev, el) {
|
|
const tagStart = ev.sender.prop('tag');
|
|
const breakLine = 'breakLine' in ev.sender.dataset;
|
|
const prefill = 'prefill' in ev.sender.dataset ? ev.sender.dataset.prefill : '';
|
|
|
|
const hasAttr = tagStart[tagStart.length - 1] === '=';
|
|
let tagEnd = tagStart;
|
|
let tagInsertStart = `[${tagStart}]${breakLine ? '\n' : ''}`;
|
|
if (hasAttr) {
|
|
tagEnd = tagEnd.slice(0, -1);
|
|
}
|
|
const tagInsertEnd = `${breakLine ? '\n' : ''}[/${tagEnd}]`;
|
|
const hasSelection = el.selectionStart !== el.selectionEnd;
|
|
const text = el.value;
|
|
|
|
if (hasSelection) {
|
|
const realStart = Math.min(el.selectionStart, el.selectionEnd);
|
|
const realEnd = Math.max(el.selectionStart, el.selectionEnd);
|
|
const selectionLength = realEnd - realStart;
|
|
|
|
const strStart = text.slice(0, realStart);
|
|
const strEnd = text.substring(realEnd);
|
|
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
|
|
const reconst = `${strStart}${frag}${strEnd}`;
|
|
el.value = reconst;
|
|
if (!hasAttr) {
|
|
el.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertEnd.length + selectionLength - 1);
|
|
} else {
|
|
const attrCursor = realStart + tagInsertEnd.length - (1 + (breakLine ? 1 : 0))
|
|
el.setSelectionRange(attrCursor, attrCursor); // cursor on attr
|
|
}
|
|
} else {
|
|
if (hasAttr) {
|
|
tagInsertStart += prefill;
|
|
}
|
|
const cursor = el.selectionStart;
|
|
const strStart = text.slice(0, cursor);
|
|
const strEnd = text.substr(cursor);
|
|
|
|
let newCursor = strStart.length + tagInsertStart.length;
|
|
if (hasAttr) {
|
|
newCursor = cursor + tagInsertStart.length - prefill.length - (1 + (breakLine ? 1 : 0)) //cursor on attr
|
|
}
|
|
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
|
|
el.value = reconst;
|
|
el.setSelectionRange(newCursor, newCursor);
|
|
}
|
|
el.focus();
|
|
}
|
|
|
|
addQuote(ev, el) {
|
|
el.value += ev.sender.value;
|
|
el.scrollIntoView();
|
|
el.focus();
|
|
}
|
|
|
|
convertTimestamps(ev, el) {
|
|
const timestamp = el.propToInt('utc');
|
|
if (!isNaN(timestamp)) {
|
|
const date = new Date(timestamp * 1000);
|
|
el.textContent = date.toLocaleString();
|
|
}
|
|
}
|
|
|
|
#currentUsername = undefined;
|
|
async highlightMentions(ev, el) {
|
|
if (this.#currentUsername === undefined) {
|
|
const userInfo = await this.api.getJSON(userEndpoint);
|
|
if (!userInfo.value) {
|
|
return;
|
|
}
|
|
this.#currentUsername = userInfo.value.user.username;
|
|
}
|
|
|
|
if (el.prop('username') === this.#currentUsername) {
|
|
el.classList.add('me');
|
|
}
|
|
}
|
|
}
|
|
|
|
export class BadgeEditorForm {
|
|
#badgeTemplate = undefined;
|
|
async loadBadgeEditor(ev, el) {
|
|
const badges = await this.api.getHTML(badgeEditorEndpoint);
|
|
if (!badges.value) {
|
|
return;
|
|
}
|
|
if (this.#badgeTemplate === undefined){
|
|
this.#badgeTemplate = document.getElementById('badge-editor-template').content.firstElementChild.outerHTML;
|
|
}
|
|
el.replaceChildren();
|
|
const addButton = `<button data-disable-if-max="1" data-receive="updateBadgeCount" DISABLE_IF_MAX type="button" data-send="addBadge">Add badge</button>`;
|
|
const submitButton = `<input data-receive="updateBadgeCount" type="submit" value="Save badges">`;
|
|
const controls = `<span>${addButton} ${submitButton} <span data-count="1" data-receive="updateBadgeCount">BADGECOUNT/10</span></span>`
|
|
const badgeCount = badges.value.querySelectorAll('.settings-badge-container').length;
|
|
const subs = [
|
|
['BADGECOUNT', badgeCount],
|
|
['DISABLE_IF_MAX', badgeCount === 10 ? 'disabled' : ''],
|
|
];
|
|
el.appendChild(this.api.makeHTML(controls, subs));
|
|
|
|
const listTemplate = document.getElementById('badges-list-template').content.firstElementChild.outerHTML;
|
|
const list = this.api.makeHTML(listTemplate).firstElementChild;
|
|
list.appendChild(badges.value);
|
|
el.appendChild(list);
|
|
}
|
|
|
|
addBadge(ev, el) {
|
|
if (this.#badgeTemplate === undefined) {
|
|
return;
|
|
}
|
|
const badge = this.api.makeHTML(this.#badgeTemplate).firstElementChild;
|
|
el.appendChild(badge);
|
|
this.api.localTrigger('updateBadgeCount');
|
|
}
|
|
|
|
deleteBadge(ev, el) {
|
|
if (!el.contains(ev.sender)) {
|
|
return;
|
|
}
|
|
el.remove();
|
|
this.api.localTrigger('updateBadgeCount');
|
|
}
|
|
|
|
updateBadgeCount(ev, el) {
|
|
const badgeCount = el.parentNode.parentNode.querySelectorAll('.settings-badge-container').length;
|
|
if (el.propToInt('disableIfMax') === 1) {
|
|
el.disabled = badgeCount === 10;
|
|
} else if (el.propToInt('count') === 1) {
|
|
el.textContent = `${badgeCount}/10`;
|
|
}
|
|
}
|
|
|
|
badgeEditorPrepareSubmit(ev, el) {
|
|
if (ev.type !== 'submit') {
|
|
return;
|
|
}
|
|
ev.preventDefault();
|
|
|
|
const badges = el.querySelectorAll('.settings-badge-container').length;
|
|
|
|
const noUploads = el.querySelectorAll('.settings-badge-file-picker.hidden input[type=file]');
|
|
noUploads.forEach(e => {
|
|
e.value = null;
|
|
})
|
|
el.submit();
|
|
}
|
|
}
|
|
|
|
const validateBase64Img = dataURL => new Promise(resolve => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
resolve(img.width === 88 && img.height === 31);
|
|
};
|
|
img.src = dataURL;
|
|
});
|
|
|
|
export class BadgeEditorBadge {
|
|
#badgeCustomImageData = null;
|
|
badgeUpdatePreview(ev, el) {
|
|
if (ev.type !== 'change') {
|
|
return;
|
|
}
|
|
// TODO: ev.sender doesn't have a bittyParent
|
|
const selectBittyParent = ev.sender.closest('bitty-7-0');
|
|
if (el.bittyParent !== selectBittyParent) {
|
|
return;
|
|
}
|
|
|
|
if (ev.value === 'custom') {
|
|
if (this.#badgeCustomImageData) {
|
|
el.src = this.#badgeCustomImageData;
|
|
} else {
|
|
el.removeAttribute('src');
|
|
}
|
|
return;
|
|
}
|
|
const option = ev.sender.selectedOptions[0];
|
|
el.src = option.dataset.filePath;
|
|
}
|
|
|
|
async badgeUpdatePreviewCustom(ev, el) {
|
|
if (ev.type !== 'change') {
|
|
return;
|
|
}
|
|
if (el.bittyParent !== ev.sender.bittyParent) {
|
|
return;
|
|
}
|
|
|
|
const file = ev.target.files[0];
|
|
if (file.size >= 1000 * 500) {
|
|
this.api.localTrigger('badgeErrorSize');
|
|
this.#badgeCustomImageData = null;
|
|
el.removeAttribute('src');
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = async e => {
|
|
const dimsValid = await validateBase64Img(e.target.result);
|
|
if (!dimsValid) {
|
|
this.api.localTrigger('badgeErrorDim');
|
|
this.#badgeCustomImageData = null;
|
|
el.removeAttribute('src');
|
|
return;
|
|
}
|
|
this.#badgeCustomImageData = e.target.result;
|
|
el.src = this.#badgeCustomImageData;
|
|
this.api.localTrigger('badgeHideErrors');
|
|
}
|
|
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
badgeToggleFilePicker(ev, el) {
|
|
if (ev.type !== 'change') {
|
|
return;
|
|
}
|
|
// TODO: ev.sender doesn't have a bittyParent
|
|
const selectBittyParent = ev.sender.closest('bitty-7-0');
|
|
if (el.bittyParent !== selectBittyParent) {
|
|
return;
|
|
}
|
|
const filePicker = el.querySelector('input[type=file]');
|
|
if (ev.value === 'custom') {
|
|
el.classList.remove('hidden');
|
|
if (filePicker.dataset.validity) {
|
|
filePicker.setCustomValidity(filePicker.dataset.validity);
|
|
}
|
|
filePicker.required = true;
|
|
} else {
|
|
el.classList.add('hidden');
|
|
filePicker.setCustomValidity('');
|
|
filePicker.required = false;
|
|
}
|
|
}
|
|
|
|
openBadgeFilePicker(ev, el) {
|
|
// TODO: ev.sender doesn't have a bittyParent
|
|
if (ev.sender.parentNode !== el.parentNode) {
|
|
return;
|
|
}
|
|
el.click();
|
|
}
|
|
|
|
badgeErrorSize(ev, el) {
|
|
const validity = "Image can't be over 500KB."
|
|
el.dataset.validity = validity;
|
|
el.setCustomValidity(validity);
|
|
el.reportValidity();
|
|
}
|
|
|
|
badgeErrorDim(ev, el) {
|
|
const validity = "Image must be exactly 88x31 pixels."
|
|
el.dataset.validity = validity;
|
|
el.setCustomValidity(validity);
|
|
el.reportValidity();
|
|
}
|
|
|
|
badgeHideErrors(ev, el) {
|
|
delete el.dataset.validity;
|
|
el.setCustomValidity('');
|
|
}
|
|
}
|
|
|
|
const getCollectionDataForEl = el => {
|
|
const nameInput = el.querySelector(".collection-name");
|
|
const collectionId = el.dataset.collectionId;
|
|
|
|
return {
|
|
id: collectionId,
|
|
name: nameInput.value,
|
|
is_new: !('collectionId' in el.dataset),
|
|
};
|
|
}
|
|
|
|
export class CollectionsEditor {
|
|
#collectionTemplate = undefined;
|
|
#collectionsData = [];
|
|
#removedCollections = [];
|
|
#valid = true;
|
|
|
|
addCollection(ev, el) {
|
|
if (this.#collectionTemplate === undefined) {
|
|
this.#collectionTemplate = document.getElementById('new-collection-template').content;
|
|
}
|
|
// interesting
|
|
const newCollection = this.api.makeHTML(this.#collectionTemplate.firstElementChild.outerHTML);
|
|
el.appendChild(newCollection);
|
|
}
|
|
|
|
deleteCollection(ev, el) {
|
|
if (!el.contains(ev.sender)) {
|
|
return;
|
|
}
|
|
if ('collectionId' in el.dataset) {
|
|
this.#removedCollections.push(el.dataset.collectionId);
|
|
}
|
|
el.remove();
|
|
}
|
|
|
|
async saveCollections(ev, el) {
|
|
this.#valid = true;
|
|
this.api.localTrigger('testValidity');
|
|
if (!this.#valid) {
|
|
return;
|
|
}
|
|
this.#collectionsData = [];
|
|
this.api.localTrigger('getCollectionData');
|
|
|
|
const data = {
|
|
collections: this.#collectionsData,
|
|
removed_collections: this.#removedCollections,
|
|
};
|
|
|
|
const res = await this.api.getJSON(el.prop('submitHref'), [], {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (res.error) {
|
|
return;
|
|
}
|
|
|
|
window.location.reload();
|
|
}
|
|
|
|
getCollectionData(ev, el) {
|
|
this.#collectionsData.push(getCollectionDataForEl(el));
|
|
}
|
|
|
|
testValidity(ev, el) {
|
|
const input = el.querySelector('input');
|
|
if (!input.validity.valid) {
|
|
input.reportValidity();
|
|
this.#valid = false;
|
|
}
|
|
}
|
|
}
|