289 lines
9.8 KiB
JavaScript
289 lines
9.8 KiB
JavaScript
"use strict";
|
|
|
|
function autosizeTextarea(textarea, shadow) {
|
|
shadow.style.width = textarea.clientWidth + "px";
|
|
shadow.value = textarea.value;
|
|
textarea.style.height = shadow.scrollHeight + "px";
|
|
}
|
|
|
|
function queryArgsFromForm(form) {
|
|
const items = [];
|
|
for (const {name, value, type, checked} of form.elements) {
|
|
if (!name) continue;
|
|
if (type === "radio" && !checked) continue;
|
|
items.push(encodeURIComponent(name) + '=' + encodeURIComponent(value));
|
|
}
|
|
return items.join('&');
|
|
}
|
|
|
|
function isEdited(form) {
|
|
for (const {name, value, defaultValue, checked, defaultChecked} of form.elements) {
|
|
if (name && ((value !== defaultValue) || (checked !== defaultChecked))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function instantiate(templateId) {
|
|
return document.getElementById(templateId).firstElementChild.cloneNode(true);
|
|
}
|
|
|
|
function popup(dialog) {
|
|
document.body.appendChild(dialog);
|
|
dialog.querySelector(".primary").focus();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
function handler(ev) {
|
|
document.body.removeChild(dialog);
|
|
resolve(ev.target.getAttribute("data-value"));
|
|
}
|
|
|
|
const buttons = dialog.querySelectorAll('.btn-row>*');
|
|
for (let i = 0; i < buttons.length; ++i)
|
|
buttons[i].addEventListener("click", handler);
|
|
});
|
|
}
|
|
|
|
function loginDialog(loginUrl) {
|
|
const dialog = instantiate("login");
|
|
dialog.querySelector("a").setAttribute("href", loginUrl);
|
|
return popup(dialog);
|
|
}
|
|
|
|
function alertAsync(message) {
|
|
const dialog = instantiate("alert");
|
|
dialog.querySelector(".message").textContent = message;
|
|
return popup(dialog);
|
|
}
|
|
|
|
function confirmDiscard() {
|
|
return popup(instantiate("confirm-discard"));
|
|
}
|
|
|
|
const state = {
|
|
hasBeenOpen: false,
|
|
saving: false,
|
|
editing: function () { return document.querySelector(".container").classList.contains('edit'); },
|
|
hasCancelUrl: function () { return document.querySelector("a.button-cancel").getAttribute('href') !== ""; }
|
|
};
|
|
|
|
function openEditor() {
|
|
const bodyElement = document.querySelector("body");
|
|
const container = document.querySelector(".container");
|
|
const rendered = container.querySelector(".rendered");
|
|
const editor = container.querySelector(".editor");
|
|
const textarea = editor.querySelector('textarea[name="body"]');
|
|
const shadow = editor.querySelector('textarea.shadow-control');
|
|
const form = document.getElementById('article-editor');
|
|
const cancel = form.querySelector('.cancel');
|
|
const cancelButton = form.querySelector('button.button-cancel');
|
|
const cancelInteractionGroup = form.querySelector(".cancel-interaction-group");
|
|
|
|
const footer = document.querySelector("footer");
|
|
const lastUpdated = footer.querySelector(".last-updated");
|
|
|
|
textarea.style.height = rendered.clientHeight + "px";
|
|
|
|
retainScrollRatio(() => {
|
|
container.classList.add('edit');
|
|
autosizeTextarea(textarea, shadow);
|
|
});
|
|
updateFormEnabledState();
|
|
|
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
|
|
if (state.hasBeenOpen) return;
|
|
state.hasBeenOpen = true;
|
|
|
|
textarea.addEventListener('input', () => autosizeTextarea(textarea, shadow));
|
|
window.addEventListener('resize', () => autosizeTextarea(textarea, shadow));
|
|
|
|
function updateFormEnabledState() {
|
|
const baseEnabled = !state.saving && state.editing();
|
|
const enabled = {
|
|
cancel: baseEnabled && state.hasCancelUrl(),
|
|
};
|
|
|
|
cancelInteractionGroup.classList.remove(!enabled.cancel ? "interaction-group--root--enabled" : "interaction-group--root--disabled");
|
|
cancelInteractionGroup.classList.add(enabled.cancel ? "interaction-group--root--enabled" : "interaction-group--root--disabled");
|
|
|
|
for (const el of form.elements) {
|
|
el.disabled = !baseEnabled;
|
|
}
|
|
|
|
cancelButton.disabled = true;
|
|
|
|
// TODO: edit-link in footer?
|
|
}
|
|
|
|
function retainScrollRatio(innerFunction) {
|
|
const scrollElement = document.body.parentElement;
|
|
const savedScrollRatio = scrollElement.scrollTop / (scrollElement.scrollHeight - scrollElement.clientHeight);
|
|
innerFunction();
|
|
scrollElement.scrollTop = (scrollElement.scrollHeight - scrollElement.clientHeight) * savedScrollRatio;
|
|
}
|
|
|
|
function closeEditor() {
|
|
retainScrollRatio(() => container.classList.remove('edit'));
|
|
document.activeElement && document.activeElement.blur();
|
|
}
|
|
|
|
function doSave() {
|
|
state.saving = true;
|
|
updateFormEnabledState();
|
|
|
|
const body = queryArgsFromForm(form);
|
|
|
|
fetch(
|
|
form.getAttribute("action"),
|
|
{
|
|
method: 'PUT',
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded"
|
|
},
|
|
body: body,
|
|
credentials: "same-origin",
|
|
}
|
|
).then(response => {
|
|
// I don't know how to more precisely determine that we hit a login redirect:
|
|
const probablyLoginRedirect = response.redirected &&
|
|
(response.headers.get("content-type") !== "application/json");
|
|
|
|
if (probablyLoginRedirect) {
|
|
return loginDialog(response.url)
|
|
.then(() => {
|
|
state.saving = false;
|
|
updateFormEnabledState();
|
|
});
|
|
}
|
|
|
|
if (!response.ok) throw new Error("Unexpected status code (" + response.status + ")");
|
|
|
|
return response.json()
|
|
.then(result => {
|
|
// Update url-bar, page title, footer and cancel link
|
|
const url = result.slug == "" ? "." : result.slug;
|
|
window.history.replaceState(null, result.title, url);
|
|
cancel.setAttribute("href", url);
|
|
document.querySelector("title").textContent = result.title;
|
|
lastUpdated.innerHTML = result.last_updated;
|
|
lastUpdated.classList.remove("missing");
|
|
|
|
// Update body:
|
|
rendered.innerHTML = result.rendered;
|
|
|
|
form.elements.title.value = result.title;
|
|
shadow.value = textarea.value = result.body;
|
|
|
|
form.querySelector(`.theme-picker--option[value=${JSON.stringify(result.theme)}]`).checked = true;
|
|
bodyElement.className = `theme-${result.theme}`;
|
|
|
|
// Update form:
|
|
form.elements.base_revision.value = result.revision;
|
|
for (const element of form.elements) {
|
|
element.defaultValue = element.value;
|
|
element.defaultChecked = element.checked;
|
|
}
|
|
|
|
if (!result.conflict) {
|
|
closeEditor();
|
|
}
|
|
|
|
state.saving = false;
|
|
updateFormEnabledState();
|
|
autosizeTextarea(textarea, shadow);
|
|
|
|
if (result.conflict) {
|
|
return alertAsync("Your edit came into conflict with another change " +
|
|
"and has not been saved.\n" +
|
|
"Please resolve the merge conflict and save again.");
|
|
}
|
|
});
|
|
}).catch(err => {
|
|
state.saving = false;
|
|
updateFormEnabledState();
|
|
console.error(err);
|
|
return alertAsync(err.toString());
|
|
});
|
|
}
|
|
|
|
function doCancel() {
|
|
Promise.resolve(!isEdited(form) || confirmDiscard())
|
|
.then(doReset => {
|
|
if (doReset) {
|
|
closeEditor();
|
|
updateFormEnabledState();
|
|
form.reset();
|
|
|
|
let selectedTheme = form.querySelector(`.theme-picker--option[checked]`).value;
|
|
bodyElement.className = `theme-${selectedTheme}`;
|
|
}
|
|
});
|
|
}
|
|
|
|
form.addEventListener("submit", function (ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
doSave();
|
|
});
|
|
|
|
cancel.addEventListener('click', function (ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
doCancel();
|
|
});
|
|
|
|
window.addEventListener("beforeunload", function (ev) {
|
|
if (isEdited(form)) {
|
|
ev.preventDefault();
|
|
return ev.returnValue = "Discard changes?";
|
|
}
|
|
});
|
|
|
|
document.addEventListener("keypress", function (ev) {
|
|
const accel = ev.ctrlKey || ev.metaKey; // Imprecise, but works cross platform
|
|
if (ev.key === "Enter" && accel) {
|
|
if (!state.editing()) return;
|
|
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
|
|
doSave();
|
|
}
|
|
});
|
|
|
|
const themeOptions = form.querySelectorAll(".theme-picker--option");
|
|
for (let themeOption of themeOptions) {
|
|
themeOption.addEventListener("click", function (ev) {
|
|
bodyElement.className = `theme-${ev.target.value}`;
|
|
});
|
|
}
|
|
}
|
|
|
|
function initializeTheme() {
|
|
const form = document.getElementById('article-editor');
|
|
|
|
let preSelectedTheme = form.querySelector(`.theme-picker--option[checked]`);
|
|
if (preSelectedTheme) return;
|
|
|
|
let themes = form.querySelectorAll(`.theme-picker--option`);
|
|
let randomThemeId = (Math.random() * themes.length) | 0;
|
|
|
|
let theme = themes[randomThemeId];
|
|
theme.defaultChecked = theme.checked = true;
|
|
document.querySelector("body").className = `theme-${theme.value}`;
|
|
}
|
|
|
|
initializeTheme();
|
|
|
|
document
|
|
.getElementById("openEditor")
|
|
.addEventListener("click", function (ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
|
|
openEditor();
|
|
})
|
|
|
|
if (document.querySelector(".container").classList.contains("edit")) {
|
|
openEditor();
|
|
}
|