Commit 46e49b1c authored by Henrik Tramberend's avatar Henrik Tramberend
Browse files

Merge branch 'master' into geometry-diagrams

parents ca2f6db2 b6d0c421
......@@ -83,4 +83,4 @@ if ($local) {
Write-Host "To then call decker from anywhere on the PowerShell command line create a PowerShell profile file, add the following line, and restart your PowerShell session!" -ForegroundColor Green
Write-Host '$Env:Path += ";${Env:ProgramFiles(x86)}\Decker\bin"' -ForegroundColor Green
}
}
\ No newline at end of file
......@@ -76,4 +76,9 @@ clean:
rm -rf dist public
rm -rf resource/support/vendor
clean-recordings:
rm -f test/decks/*-recording.*
rm -f test/decks/*-times.json
rm -f test/decks/*-annot.json
.PHONY: build clean test install dist docs resource-zip css
......@@ -15,19 +15,18 @@ div.open-button:not(.checked) {
div.open-button {
position: fixed;
left: auto;
right: calc(var(--whiteboard-icon-size) * 0.25);
top: calc(var(--whiteboard-icon-size) * 0.25);
bottom: auto;
margin: calc(var(--whiteboard-icon-size) * 0.5);
padding: 0.2em;
z-index: 40;
background: none;
font-size: var(--whiteboard-icon-size);
color: var(--whiteboard-inactive-color);
left: auto;
right: calc(var(--whiteboard-icon-size) * 0.25);
top: calc(var(--whiteboard-icon-size) * 0.25);
bottom: auto;
margin: calc(var(--whiteboard-icon-size) * 0.5);
padding: 0.2em;
z-index: 40;
background: none;
font-size: var(--whiteboard-icon-size);
color: var(--whiteboard-inactive-color);
transition-property: visibility;
}
div.open-button.i {
......@@ -35,8 +34,8 @@ div.open-button.i {
}
div.open-button:hover {
opacity: 1.0;
color: var(--whiteboard-active-color);
opacity: 1;
color: var(--whiteboard-active-color);
}
/* Badge for the open button*/
......@@ -229,6 +228,25 @@ div.q-panel div.q-list div.item div.content {
overflow: hidden;
}
div.q-panel div.q-list div.item.answer {
display: block;
background-color: rgba(230, 230, 230, 0.9);
margin-left: 4ex;
font-size: 80%;
}
div.item.answer > div.link {
float: right;
}
div.item.answer > div.description {
}
div.q-panel div.answer div.controls {
float: right;
margin-left: 0.5em;
}
div.q-panel div.content div.controls {
float: right;
margin-left: 0.5em;
......@@ -289,6 +307,11 @@ div.q-panel div.q-input textarea {
font-size: 0.8em;
}
div.q-panel div.q-input textarea[answer] {
background-color: #efe;
border: solid 1px #cfc;
}
div.q-panel div.q-footer {
flex: 0 1 content;
......@@ -448,7 +471,6 @@ section#questions-overview table tr td:first-child {
}
li.slide-menu-item[data-questions]::after {
content: attr(data-questions);
margin-left: 1em;
font-size: 0.9em;
......@@ -467,9 +489,6 @@ li.slide-menu-item[data-questions]::after {
border-radius: 0.4em;
padding: 0.2em 0.4em;
}
li.slide-menu-item[data-questions][data-answered=true]::after {
li.slide-menu-item[data-questions][data-answered="true"]::after {
background-color: green;
}
......@@ -308,3 +308,42 @@ function renderSvgMath() {
script.parentNode.removeChild(script);
}
}
// List of predicates that all must return true for a requested reload to
// actually be performed.
let reloadInhibitors = [];
// Adds a reload inhibitor.
function addReloadInhibitor(predicate) {
reloadInhibitors.push(predicate);
}
// Removes a reload inhibitor.
function removeReloadInhibitor(predicate) {
reloadInhibitors.splice(
reloadInhibitors.find((p) => p === predicate),
1
);
}
// Opens a web socket connection and listens to reload requests from the server.
// If all of the registered inhibitors return true, the reload is performed.
function openReloadSocket() {
if (location.hostname == "localhost" || location.hostname == "0.0.0.0") {
var socket = new WebSocket("ws://" + location.host + "/reload");
socket.onmessage = function (event) {
if (event.data.startsWith("reload!")) {
console.log("Reload requested.");
let reload = reloadInhibitors.reduce((a, p) => a && p(), true);
if (reload) {
console.log("Reload authorized.");
window.location.reload();
} else {
console.log("Reload inhibited.");
}
}
};
}
}
window.addEventListener("load", openReloadSocket);
......@@ -8,24 +8,25 @@ var timeout = 500;
let engine = {
api: undefined,
deckId: undefined, // The unique deck identifier.
token: undefined
token: undefined,
};
// Contacts the engine API at base.
function contactEngine(base, deckId) {
engine.deckId = deckId || deckUrl();
// Try to import the API utility module.
// Try to import the API utility module. We need to do this dynamically
// because the URL is constructed from configuration data.
import(base + "/decker-util.js")
.then(util => {
.then((util) => {
console.log("Decker engine contacted at: ", base);
engine.api = util.buildApi(base);
prepareEngine();
})
.catch(e => {
.catch((e) => {
console.log("Can't contact decker engine:" + e);
console.log("Retrying ..." + e);
setTimeout(() => contactEngine(base, deckId), (timeout *= 2));
// console.log("Retrying ..." + e);
// setTimeout(() => contactEngine(base, deckId), (timeout *= 2));
});
}
......@@ -43,7 +44,7 @@ function deckUrl() {
function prepareEngine() {
engine.api
.getToken()
.then(token => {
.then((token) => {
// Globally set the server token.
engine.token = token;
......@@ -51,12 +52,12 @@ function prepareEngine() {
if (Reveal.isReady()) {
buildInterface();
} else {
Reveal.addEventListener("ready", _ => {
Reveal.addEventListener("ready", (_) => {
buildInterface();
});
}
})
.catch(e => {
.catch((e) => {
// Nothing goes without a token
console.log("API function getToken() failed: " + e);
throw e;
......@@ -142,13 +143,12 @@ function buildInterface() {
input.classList.add("q-input");
input.appendChild(text);
text.setAttribute("wrap", "hard");
text.setAttribute(
"placeholder",
"Type question, ⇧⏎ (Shift-Return) to enter"
);
text.placeholder =
"Type question, ⇧⏎ (Shift-Return) to enter. Use Markdown for formatting.";
// prevent propagating keypress up to Reveal, since otherwise '?'
// triggers the help dialog.
text.addEventListener("keypress", e => {
text.addEventListener("keypress", (e) => {
e.stopPropagation();
});
......@@ -197,7 +197,7 @@ function buildInterface() {
check.classList.remove("checked");
user.type = "text";
}
};
}
function updateComments() {
let slideId = Reveal.getCurrentSlide().id;
......@@ -205,21 +205,25 @@ function buildInterface() {
.getComments(engine.deckId, slideId, engine.token.admin || user.value)
.then(renderList)
.catch(console.log);
};
}
function renderSubmit() {
updateCommentsAndMenu();
text.value = "";
text.commentId = null;
text.answered = null;
};
text.removeAttribute("answer");
text.placeholder =
"Type question, ⇧⏎ (Shift-Return) to enter. Use Markdown for formatting.";
}
// given the list of questions, update question counter of menu items
function updateMenuItems(list) {
document.querySelectorAll('ul.slide-menu-items > li.slide-menu-item').forEach( (li) => {
li.removeAttribute('data-questions');
li.removeAttribute('data-answered');
});
document
.querySelectorAll("ul.slide-menu-items > li.slide-menu-item")
.forEach((li) => {
li.removeAttribute("data-questions");
li.removeAttribute("data-answered");
});
for (let comment of list) {
// get slide info
......@@ -229,29 +233,32 @@ function buildInterface() {
const indices = Reveal.getIndices(slide);
// build query string, get menu item
let query = 'ul.slide-menu-items > li.slide-menu-item';
if (indices.h) query += '[data-slide-h=\"' + indices.h + '\"]';
if (indices.v) query += '[data-slide-v=\"' + indices.v + '\"]';
let query = "ul.slide-menu-items > li.slide-menu-item";
if (indices.h) query += '[data-slide-h="' + indices.h + '"]';
if (indices.v) query += '[data-slide-v="' + indices.v + '"]';
let li = document.querySelector(query);
// update question counter
if (li) {
let questions = li.hasAttribute('data-questions') ? parseInt(li.getAttribute('data-questions')) : 0;
let answered = li.hasAttribute('data-answered') ? (li.getAttribute('data-answered')==='true') : true;
let questions = li.hasAttribute("data-questions")
? parseInt(li.getAttribute("data-questions"))
: 0;
let answered = li.hasAttribute("data-answered")
? li.getAttribute("data-answered") === "true"
: true;
questions = questions + 1;
answered = answered && (comment.answers.length > 0);
answered = answered && comment.answers.length > 0;
li.setAttribute('data-questions', questions);
li.setAttribute('data-answered', answered);
li.setAttribute("data-questions", questions);
li.setAttribute("data-answered", answered);
}
}
else {
} else {
// slide not found. should not happen. user probably used wrong (duplicate) deckID.
console.warn("Could not find slide " + slideID);
}
}
};
}
// query list of questions, then update menu items
function updateMenu() {
......@@ -259,31 +266,30 @@ function buildInterface() {
.getComments(engine.deckId)
.then(updateMenuItems)
.catch(console.log);
};
}
function updateCommentsAndMenu() {
updateComments();
updateMenu();
};
}
function isAdmin() {
return engine.token.admin !== null;
};
}
function isAuthor(comment) {
return comment.author === user.value;
}
function canDelete(comment) {
return isAdmin() || isAuthor(comment);
};
return isAdmin() || isAuthor(comment) && comment.answers.length == 0;
}
function renderList(list) {
// have all questions been answered?
let allAnswered = true;
for (let comment of list) {
const isAnswered = (comment.answers && comment.answers.length > 0);
const isAnswered = comment.answers && comment.answers.length > 0;
if (!isAnswered) {
allAnswered = false;
break;
......@@ -298,8 +304,7 @@ function buildInterface() {
if (allAnswered) {
counter.classList.add("answered");
badge.classList.add("answered");
}
else {
} else {
counter.classList.remove("answered");
badge.classList.remove("answered");
}
......@@ -311,7 +316,6 @@ function buildInterface() {
// re-fill question container
for (let comment of list) {
// create question item
let item = document.createElement("div");
item.classList.add("item");
......@@ -348,10 +352,10 @@ function buildInterface() {
if (comment.didvote) {
vote.classList.add("didvote");
}
vote.addEventListener("click", _ => {
vote.addEventListener("click", (_) => {
let vote = {
comment: comment.id,
voter: user.value
voter: user.value,
};
engine.api.voteComment(vote).then(updateComments);
});
......@@ -365,10 +369,12 @@ function buildInterface() {
let mod = document.createElement("button");
mod.className = "fas fa-edit";
mod.title = "Edit question";
mod.addEventListener("click", _ => {
mod.addEventListener("click", (_) => {
text.value = comment.markdown;
text.commentId = comment.id;
text.answered = comment.answered;
text.removeAttribute("answer");
text.placeholder =
"Type question, ⇧⏎ (Shift-Return) to enter. Use Markdown for formatting.";
text.focus();
});
box.appendChild(mod);
......@@ -377,7 +383,7 @@ function buildInterface() {
let del = document.createElement("button");
del.className = "fas fa-trash-alt";
del.title = "Delete question";
del.addEventListener("click", _ => {
del.addEventListener("click", (_) => {
engine.api
.deleteComment(comment.id, engine.token.admin || user.value)
.then(updateCommentsAndMenu);
......@@ -385,29 +391,51 @@ function buildInterface() {
box.appendChild(del);
}
if (isAdmin()) {
let add = document.createElement("button");
add.className = "far fa-plus-square";
add.title = "Add answer";
add.addEventListener("click", (_) => {
text.value = "";
text.commentId = comment.id;
text.setAttribute("answer", "true");
text.placeholder =
"Type answer, ⇧⏎ (Shift-Return) to enter. Use Markdown for formatting.";
text.focus();
});
box.appendChild(add);
}
// Answered button
let answeredButton = document.createElement("button");
const isAnswered = (comment.answers && comment.answers.length > 0);
const isAnswered = comment.answers && comment.answers.length > 0;
const canAnswer = canDelete(comment);
if (isAnswered) {
answeredButton.className = "far fa-check-circle answered";
answeredButton.title = canAnswer ? "Mark as not answered" : "Question has been answered";
answeredButton.title = canAnswer
? "Mark as not answered"
: "Question has been answered";
if (isAdmin()) {
answeredButton.addEventListener('click', _ => {
console.log("hallo mario")
engine.api
.deleteAnswer(comment.answers[0].id, engine.token.admin || user.value)
.then(updateCommentsAndMenu);
answeredButton.addEventListener("click", (_) => {
let chain = Promise.resolve();
for (let answer of comment.answers) {
chain = chain.then(() =>
engine.api.deleteAnswer(answer.id, engine.token.admin)
);
}
chain.then(updateCommentsAndMenu);
});
}
} else {
answeredButton.className = "far fa-circle notanswered";
answeredButton.title = canAnswer ? "Mark as answered" : "Question has not been answered";
answeredButton.title = canAnswer
? "Mark as answered"
: "Question has not been answered";
if (isAdmin()) {
answeredButton.addEventListener('click', _ => {
console.log("hallo mario")
answeredButton.addEventListener("click", (_) => {
engine.api
.postAnswer(comment.id, engine.token.admin || user.value)
.postAnswer(comment.id, engine.token.admin)
.then(updateCommentsAndMenu);
});
}
......@@ -417,24 +445,78 @@ function buildInterface() {
// add question to container
container.appendChild(item);
MathJax.typeset([item]);
// add answers after the question
for (let answer of comment.answers) {
if (!answer.link && !answer.html) break;
let answerBlock = document.createElement("div");
answerBlock.classList.add("item", "answer");
if (isAdmin()) {
// answer controls
let abox = document.createElement("div");
abox.classList.add("controls");
answerBlock.insertBefore(abox, answerBlock.firstChild);
// Delete button
let del = document.createElement("button");
del.className = "fas fa-trash-alt";
del.title = "Delete answer";
del.addEventListener("click", (_) => {
engine.api
.deleteAnswer(answer.id, engine.token.admin)
.then(updateCommentsAndMenu);
});
abox.appendChild(del);
}
if (answer.link) {
try {
let url = new URL(answer.link);
answerBlock.insertAdjacentHTML(
"beforeend",
`<div class="link">
<a href="${url}" target="_blank">
<i class="fas fa-external-link-alt"></i>
</a>
</div>`
);
} catch (_) {}
}
if (answer.html) {
answerBlock.insertAdjacentHTML(
"beforeend",
`<div class="description">${answer.html}</div>`
);
}
container.appendChild(answerBlock);
MathJax.typeset([answerBlock]);
}
}
container.scrollTop = 0;
};
close.addEventListener("click", _ => {
if (localStorage.getItem("question-panel") == "open") {
open.classList.add("checked");
panel.classList.add("open");
}
}
close.addEventListener("click", (_) => {
open.classList.remove("checked");
panel.classList.remove("open");
localStorage.removeItem("question-panel");
});
open.addEventListener("click", _ => {
open.addEventListener("click", (_) => {
open.classList.add("checked");
panel.classList.add("open");
updateComments();
document.activeElement.blur();
localStorage.setItem("question-panel", "open");
});
login.addEventListener("click", _ => {
login.addEventListener("click", (_) => {
if (login.classList.contains("admin")) {
engine.token.admin = null;
username.value = "";
......@@ -451,7 +533,7 @@ function buildInterface() {
}
});
password.addEventListener("keydown", e => {
password.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
if (login.classList.contains("admin")) {
......@@ -466,9 +548,9 @@ function buildInterface() {
.getLogin({
login: username.value,
password: password.value,
deck: engine.deckId
deck: engine.deckId,
})
.then(token => {
.then((token) => {
engine.token.admin = token.admin;
login.classList.add("admin");
username.value = "";
......@@ -476,14 +558,14 @@ function buildInterface() {
credentials.classList.remove("visible");
updateComments();
})
.catch(_ => {
.catch((_) => {
password.value = "";
});
}
});
if (!engine.token.authorized) {
user.addEventListener("keydown", e => {
user.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
updateComments();
e.stopPropagation();
......@@ -491,7 +573,7 @@ function buildInterface() {
}
});
check.addEventListener("click", _ => {
check.addEventListener("click", (_) => {
if (check.classList.contains("checked")) {
check.classList.remove("checked");
window.localStorage.removeItem("token");
......@@ -509,20 +591,27 @@ function buildInterface() {
});
}
text.addEventListener("keydown", e => {
text.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.shiftKey) {
let slideId = Reveal.getCurrentSlide().id;
engine.api
.submitComment(
engine.deckId,
slideId,
user.value,
text.value,
text.commentId,