597 lines
17 KiB
JavaScript
597 lines
17 KiB
JavaScript
"use strict";
|
|
|
|
require("../util/polyfill.js");
|
|
const api = require("../api.js");
|
|
const templates = require("../templates.js");
|
|
const domParser = new DOMParser();
|
|
const misc = require("./misc.js");
|
|
const uri = require("./uri.js");
|
|
|
|
function _imbueId(options) {
|
|
if (!options.id) {
|
|
options.id = "gen-" + Math.random().toString(36).substring(7);
|
|
}
|
|
}
|
|
|
|
function _makeLabel(options, attrs) {
|
|
if (!options.text) {
|
|
return "";
|
|
}
|
|
if (!attrs) {
|
|
attrs = {};
|
|
}
|
|
attrs.for = options.id;
|
|
return makeElement("label", attrs, options.text);
|
|
}
|
|
|
|
function makeFileSize(fileSize) {
|
|
return misc.formatFileSize(fileSize);
|
|
}
|
|
|
|
function makeMarkdown(text) {
|
|
return misc.formatMarkdown(text);
|
|
}
|
|
|
|
function makeRelativeTime(time) {
|
|
return makeElement(
|
|
"time",
|
|
{ datetime: time, title: time },
|
|
misc.formatRelativeTime(time)
|
|
);
|
|
}
|
|
|
|
function makeThumbnail(url) {
|
|
return makeElement(
|
|
"span",
|
|
url
|
|
? {
|
|
class: "thumbnail",
|
|
style: `background-image: url(\'${url}\')`,
|
|
}
|
|
: { class: "thumbnail empty" },
|
|
makeElement("img", { alt: "thumbnail", src: url })
|
|
);
|
|
}
|
|
|
|
function makeRadio(options) {
|
|
_imbueId(options);
|
|
return makeElement(
|
|
"label",
|
|
{ for: options.id },
|
|
makeElement("input", {
|
|
id: options.id,
|
|
name: options.name,
|
|
value: options.value,
|
|
type: "radio",
|
|
checked: options.selectedValue === options.value,
|
|
disabled: options.readonly,
|
|
required: options.required,
|
|
}),
|
|
makeElement("span", { class: "radio" }, options.text)
|
|
);
|
|
}
|
|
|
|
function makeCheckbox(options) {
|
|
_imbueId(options);
|
|
return makeElement(
|
|
"label",
|
|
{ for: options.id },
|
|
makeElement("input", {
|
|
id: options.id,
|
|
name: options.name,
|
|
value: options.value,
|
|
type: "checkbox",
|
|
checked: options.checked !== undefined ? options.checked : false,
|
|
disabled: options.readonly,
|
|
required: options.required,
|
|
}),
|
|
makeElement("span", { class: "checkbox" }, options.text)
|
|
);
|
|
}
|
|
|
|
function makeSelect(options) {
|
|
return (
|
|
_makeLabel(options) +
|
|
makeElement(
|
|
"select",
|
|
{
|
|
id: options.id,
|
|
name: options.name,
|
|
disabled: options.readonly,
|
|
},
|
|
...Object.keys(options.keyValues).map((key) =>
|
|
makeElement(
|
|
"option",
|
|
{ value: key, selected: key === options.selectedKey },
|
|
options.keyValues[key]
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
function makeInput(options) {
|
|
options.value = options.value || "";
|
|
return _makeLabel(options) + makeElement("input", options);
|
|
}
|
|
|
|
function makeButton(options) {
|
|
options.type = "button";
|
|
return makeInput(options);
|
|
}
|
|
|
|
function makeTextInput(options) {
|
|
options.type = "text";
|
|
return makeInput(options);
|
|
}
|
|
|
|
function makeTextarea(options) {
|
|
const value = options.value || "";
|
|
delete options.value;
|
|
return _makeLabel(options) + makeElement("textarea", options, value);
|
|
}
|
|
|
|
function makePasswordInput(options) {
|
|
options.type = "password";
|
|
return makeInput(options);
|
|
}
|
|
|
|
function makeEmailInput(options) {
|
|
options.type = "email";
|
|
return makeInput(options);
|
|
}
|
|
|
|
function makeColorInput(options) {
|
|
const textInput = makeElement("input", {
|
|
type: "text",
|
|
value: options.value || "",
|
|
required: options.required,
|
|
class: "color",
|
|
});
|
|
const backgroundPreviewNode = makeElement("div", {
|
|
class: "preview background-preview",
|
|
style: `border-color: ${options.value};
|
|
background-color: ${options.value}`,
|
|
});
|
|
const textPreviewNode = makeElement("div", {
|
|
class: "preview text-preview",
|
|
style: `border-color: ${options.value};
|
|
color: ${options.value}`,
|
|
});
|
|
return makeElement(
|
|
"label",
|
|
{ class: "color" },
|
|
textInput,
|
|
backgroundPreviewNode,
|
|
textPreviewNode
|
|
);
|
|
}
|
|
|
|
function makeNumericInput(options) {
|
|
options.type = "number";
|
|
return makeInput(options);
|
|
}
|
|
|
|
function makeDateInput(options) {
|
|
options.type = "date";
|
|
return makeInput(options);
|
|
}
|
|
|
|
function getPostUrl(id, parameters) {
|
|
return uri.formatClientLink(
|
|
"post",
|
|
id,
|
|
parameters ? { query: parameters.query } : {}
|
|
);
|
|
}
|
|
|
|
function getPostEditUrl(id, parameters) {
|
|
return uri.formatClientLink(
|
|
"post",
|
|
id,
|
|
"edit",
|
|
parameters ? { query: parameters.query } : {}
|
|
);
|
|
}
|
|
|
|
function makePostLink(id, includeHash) {
|
|
let text = id;
|
|
if (includeHash) {
|
|
text = "@" + id;
|
|
}
|
|
return api.hasPrivilege("posts:view")
|
|
? makeElement(
|
|
"a",
|
|
{ href: uri.formatClientLink("post", id) },
|
|
misc.escapeHtml(text)
|
|
)
|
|
: misc.escapeHtml(text);
|
|
}
|
|
|
|
function makeTagLink(name, includeHash, includeCount, tag) {
|
|
const category = tag && tag.category ? tag.category : "unknown";
|
|
let text = misc.getPrettyName(name);
|
|
if (includeHash === true) {
|
|
text = "#" + text;
|
|
}
|
|
if (includeCount === true) {
|
|
text += " (" + (tag && tag.postCount ? tag.postCount : 0) + ")";
|
|
}
|
|
return api.hasPrivilege("tags:view")
|
|
? makeElement(
|
|
"a",
|
|
{
|
|
href: uri.formatClientLink("tag", name),
|
|
class: misc.makeCssName(category, "tag"),
|
|
},
|
|
misc.escapeHtml(text)
|
|
)
|
|
: makeElement(
|
|
"span",
|
|
{ class: misc.makeCssName(category, "tag") },
|
|
misc.escapeHtml(text)
|
|
);
|
|
}
|
|
|
|
function makePoolLink(id, includeHash, includeCount, pool, name) {
|
|
const category = pool && pool.category ? pool.category : "unknown";
|
|
let text = misc.getPrettyName(
|
|
name ? name : pool && pool.names ? pool.names[0] : "pool " + id
|
|
);
|
|
if (includeHash === true) {
|
|
text = "#" + text;
|
|
}
|
|
if (includeCount === true) {
|
|
text += " (" + (pool && pool.postCount ? pool.postCount : 0) + ")";
|
|
}
|
|
return api.hasPrivilege("pools:view")
|
|
? makeElement(
|
|
"a",
|
|
{
|
|
href: uri.formatClientLink("pool", id),
|
|
class: misc.makeCssName(category, "pool"),
|
|
},
|
|
misc.escapeHtml(text)
|
|
)
|
|
: makeElement(
|
|
"span",
|
|
{ class: misc.makeCssName(category, "pool") },
|
|
misc.escapeHtml(text)
|
|
);
|
|
}
|
|
|
|
function makeUserLink(user) {
|
|
let text = makeThumbnail(user ? user.avatarUrl : null);
|
|
text += user && user.name ? misc.escapeHtml(user.name) : "Anonymous";
|
|
const link =
|
|
user && user.name && api.hasPrivilege("users:view")
|
|
? makeElement(
|
|
"a",
|
|
{ href: uri.formatClientLink("user", user.name) },
|
|
text
|
|
)
|
|
: text;
|
|
return makeElement("span", { class: "user" }, link);
|
|
}
|
|
|
|
function makeFlexboxAlign(options) {
|
|
return [...misc.range(20)]
|
|
.map(() => '<li class="flexbox-dummy"></li>')
|
|
.join("");
|
|
}
|
|
|
|
function makeAccessKey(html, key) {
|
|
const regex = new RegExp("(" + key + ")", "i");
|
|
html = html.replace(
|
|
regex,
|
|
'<span class="access-key" data-accesskey="$1">$1</span>'
|
|
);
|
|
return html;
|
|
}
|
|
|
|
function _serializeElement(name, attributes) {
|
|
return [name]
|
|
.concat(
|
|
Object.keys(attributes).map((key) => {
|
|
if (attributes[key] === true) {
|
|
return key;
|
|
} else if (
|
|
attributes[key] === false ||
|
|
attributes[key] === undefined
|
|
) {
|
|
return "";
|
|
}
|
|
const attribute = misc.escapeHtml(attributes[key] || "");
|
|
return `${key}="${attribute}"`;
|
|
})
|
|
)
|
|
.join(" ");
|
|
}
|
|
|
|
function makeElement(name, attrs, ...content) {
|
|
return content.length !== undefined
|
|
? `<${_serializeElement(name, attrs)}>${content.join("")}</${name}>`
|
|
: `<${_serializeElement(name, attrs)}/>`;
|
|
}
|
|
|
|
function emptyContent(target) {
|
|
while (target.lastChild) {
|
|
target.removeChild(target.lastChild);
|
|
}
|
|
}
|
|
|
|
function replaceContent(target, source) {
|
|
emptyContent(target);
|
|
if (source instanceof NodeList) {
|
|
for (let child of [...source]) {
|
|
target.appendChild(child);
|
|
}
|
|
} else if (source instanceof Node) {
|
|
target.appendChild(source);
|
|
} else if (source !== null) {
|
|
throw `Invalid view source: ${source}`;
|
|
}
|
|
}
|
|
|
|
function showMessage(target, message, className) {
|
|
if (!message) {
|
|
message = "Unknown message";
|
|
}
|
|
const messagesHolderNode = target.querySelector(".messages");
|
|
if (!messagesHolderNode) {
|
|
return false;
|
|
}
|
|
const textNode = document.createElement("div");
|
|
textNode.innerHTML = message.replace(/\n/g, "<br/>");
|
|
textNode.classList.add("message");
|
|
textNode.classList.add(className);
|
|
const wrapperNode = document.createElement("div");
|
|
wrapperNode.classList.add("message-wrapper");
|
|
wrapperNode.appendChild(textNode);
|
|
messagesHolderNode.appendChild(wrapperNode);
|
|
return true;
|
|
}
|
|
|
|
function appendExclamationMark() {
|
|
if (!document.title.startsWith("!")) {
|
|
document.oldTitle = document.title;
|
|
document.title = `! ${document.title}`;
|
|
}
|
|
}
|
|
|
|
function showError(target, message) {
|
|
appendExclamationMark();
|
|
return showMessage(target, misc.formatInlineMarkdown(message), "error");
|
|
}
|
|
|
|
function showSuccess(target, message) {
|
|
return showMessage(target, misc.formatInlineMarkdown(message), "success");
|
|
}
|
|
|
|
function showInfo(target, message) {
|
|
return showMessage(target, misc.formatInlineMarkdown(message), "info");
|
|
}
|
|
|
|
function clearMessages(target) {
|
|
if (document.oldTitle) {
|
|
document.title = document.oldTitle;
|
|
document.oldTitle = null;
|
|
}
|
|
for (let messagesHolderNode of target.querySelectorAll(".messages")) {
|
|
emptyContent(messagesHolderNode);
|
|
}
|
|
}
|
|
|
|
function htmlToDom(html) {
|
|
// code taken from jQuery + Krasimir Tsonev's blog
|
|
const wrapMap = {
|
|
_: [1, "<div>", "</div>"],
|
|
option: [1, "<select multiple>", "</select>"],
|
|
legend: [1, "<fieldset>", "</fieldset>"],
|
|
area: [1, "<map>", "</map>"],
|
|
param: [1, "<object>", "</object>"],
|
|
thead: [1, "<table>", "</table>"],
|
|
tr: [2, "<table><tbody>", "</tbody></table>"],
|
|
td: [3, "<table><tbody><tr>", "</tr></tbody></table>"],
|
|
col: [2, "<table><tbody></tbody><colgroup>", "</colgroup></table>"],
|
|
};
|
|
wrapMap.optgroup = wrapMap.option;
|
|
wrapMap.tbody = wrapMap.thead;
|
|
wrapMap.tfoot = wrapMap.thead;
|
|
wrapMap.colgroup = wrapMap.thead;
|
|
wrapMap.caption = wrapMap.thead;
|
|
wrapMap.th = wrapMap.td;
|
|
|
|
let element = document.createElement("div");
|
|
const match = /<\s*(\w+)[^>]*?>/g.exec(html);
|
|
|
|
if (match) {
|
|
const tag = match[1];
|
|
const [depthToChild, prefix, suffix] = wrapMap[tag] || wrapMap._;
|
|
element.innerHTML = prefix + html + suffix;
|
|
for (let i = 0; i < depthToChild; i++) {
|
|
element = element.lastChild;
|
|
}
|
|
} else {
|
|
element.innerHTML = html;
|
|
}
|
|
return element.childNodes.length > 1
|
|
? element.childNodes
|
|
: element.firstChild;
|
|
}
|
|
|
|
function getTemplate(templatePath) {
|
|
if (!(templatePath in templates)) {
|
|
throw `Missing template: ${templatePath}`;
|
|
}
|
|
const templateFactory = templates[templatePath];
|
|
return (ctx) => {
|
|
if (!ctx) {
|
|
ctx = {};
|
|
}
|
|
Object.assign(ctx, {
|
|
getPostUrl: getPostUrl,
|
|
getPostEditUrl: getPostEditUrl,
|
|
makeRelativeTime: makeRelativeTime,
|
|
makeFileSize: makeFileSize,
|
|
makeMarkdown: makeMarkdown,
|
|
makeThumbnail: makeThumbnail,
|
|
makeRadio: makeRadio,
|
|
makeCheckbox: makeCheckbox,
|
|
makeSelect: makeSelect,
|
|
makeInput: makeInput,
|
|
makeButton: makeButton,
|
|
makeTextarea: makeTextarea,
|
|
makeTextInput: makeTextInput,
|
|
makePasswordInput: makePasswordInput,
|
|
makeEmailInput: makeEmailInput,
|
|
makeColorInput: makeColorInput,
|
|
makeDateInput: makeDateInput,
|
|
makePostLink: makePostLink,
|
|
makeTagLink: makeTagLink,
|
|
makePoolLink: makePoolLink,
|
|
makeUserLink: makeUserLink,
|
|
makeFlexboxAlign: makeFlexboxAlign,
|
|
makeAccessKey: makeAccessKey,
|
|
makeElement: makeElement,
|
|
makeCssName: misc.makeCssName,
|
|
makeNumericInput: makeNumericInput,
|
|
formatClientLink: uri.formatClientLink,
|
|
});
|
|
return htmlToDom(templateFactory(ctx));
|
|
};
|
|
}
|
|
|
|
function decorateValidator(form) {
|
|
// postpone showing form fields validity until user actually tries
|
|
// to submit it (seeing red/green form w/o doing anything breaks POLA)
|
|
let submitButton = form.querySelector(".buttons input");
|
|
if (!submitButton) {
|
|
submitButton = form.querySelector("input[type=submit]");
|
|
}
|
|
if (submitButton) {
|
|
submitButton.addEventListener("click", (e) => {
|
|
form.classList.add("show-validation");
|
|
});
|
|
}
|
|
form.addEventListener("submit", (e) => {
|
|
form.classList.remove("show-validation");
|
|
});
|
|
}
|
|
|
|
function disableForm(form) {
|
|
for (let input of form.querySelectorAll("input")) {
|
|
input.disabled = true;
|
|
}
|
|
}
|
|
|
|
function enableForm(form) {
|
|
for (let input of form.querySelectorAll("input")) {
|
|
input.disabled = false;
|
|
}
|
|
}
|
|
|
|
function syncScrollPosition() {
|
|
window.requestAnimationFrame(() => {
|
|
if (
|
|
history.state &&
|
|
Object.prototype.hasOwnProperty.call(history.state, "scrollX")
|
|
) {
|
|
window.scrollTo(history.state.scrollX, history.state.scrollY);
|
|
} else {
|
|
window.scrollTo(0, 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
function slideDown(element) {
|
|
const duration = 500;
|
|
return new Promise((resolve, reject) => {
|
|
const height = element.getBoundingClientRect().height;
|
|
element.style.maxHeight = "0";
|
|
element.style.overflow = "hidden";
|
|
window.setTimeout(() => {
|
|
element.style.transition = `all ${duration}ms ease`;
|
|
element.style.maxHeight = `${height}px`;
|
|
}, 50);
|
|
window.setTimeout(() => {
|
|
resolve();
|
|
}, duration);
|
|
});
|
|
}
|
|
|
|
function slideUp(element) {
|
|
const duration = 500;
|
|
return new Promise((resolve, reject) => {
|
|
const height = element.getBoundingClientRect().height;
|
|
element.style.overflow = "hidden";
|
|
element.style.maxHeight = `${height}px`;
|
|
element.style.transition = `all ${duration}ms ease`;
|
|
window.setTimeout(() => {
|
|
element.style.maxHeight = 0;
|
|
}, 10);
|
|
window.setTimeout(() => {
|
|
resolve();
|
|
}, duration);
|
|
});
|
|
}
|
|
|
|
function monitorNodeRemoval(monitoredNode, callback) {
|
|
const mutationObserver = new MutationObserver((mutations) => {
|
|
for (let mutation of mutations) {
|
|
for (let node of mutation.removedNodes) {
|
|
if (node.contains(monitoredNode)) {
|
|
mutationObserver.disconnect();
|
|
callback();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
mutationObserver.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
}
|
|
|
|
document.addEventListener("input", (e) => {
|
|
if (e.target.classList.contains("color")) {
|
|
let bkNode = e.target.parentNode.querySelector(".background-preview");
|
|
let textNode = e.target.parentNode.querySelector(".text-preview");
|
|
bkNode.style.backgroundColor = e.target.value;
|
|
bkNode.style.borderColor = e.target.value;
|
|
textNode.style.color = e.target.value;
|
|
textNode.style.borderColor = e.target.value;
|
|
}
|
|
});
|
|
|
|
// prevent opening buttons in new tabs
|
|
document.addEventListener("click", (e) => {
|
|
if (e.target.getAttribute("href") === "" && e.which === 2) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
module.exports = {
|
|
htmlToDom: htmlToDom,
|
|
getTemplate: getTemplate,
|
|
emptyContent: emptyContent,
|
|
replaceContent: replaceContent,
|
|
enableForm: enableForm,
|
|
disableForm: disableForm,
|
|
decorateValidator: decorateValidator,
|
|
makeTagLink: makeTagLink,
|
|
makePostLink: makePostLink,
|
|
makePoolLink: makePoolLink,
|
|
makeCheckbox: makeCheckbox,
|
|
makeRadio: makeRadio,
|
|
syncScrollPosition: syncScrollPosition,
|
|
slideDown: slideDown,
|
|
slideUp: slideUp,
|
|
monitorNodeRemoval: monitorNodeRemoval,
|
|
clearMessages: clearMessages,
|
|
appendExclamationMark: appendExclamationMark,
|
|
showError: showError,
|
|
showSuccess: showSuccess,
|
|
showInfo: showInfo,
|
|
};
|