431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
/* Add callbacks on text formatting buttons */
|
|
|
|
/* Locate the editor associated to an edition event.
|
|
event: Global event emitted by one of the editor buttons
|
|
Returns [the div.editor, the textarea] */
|
|
function editor_event_source(event)
|
|
{
|
|
let editor = undefined;
|
|
|
|
/* Grab the the parent editor block. The onclick event itself
|
|
usually reports the SVG in the button as the source */
|
|
let node = event.current || event.srcElement;
|
|
while (node != document.body) {
|
|
if (node.classList.contains("editor") && !editor) {
|
|
editor = node;
|
|
break;
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
if (!editor) return;
|
|
|
|
const ta = editor.querySelector(".editor textarea");
|
|
return [editor, ta];
|
|
}
|
|
|
|
/* Replace the range [start:end) with the new contents, and returns the new
|
|
interval [start:end) (ie. the range where the contents are now located). */
|
|
function editor_replace_range(textarea, start, end, contents)
|
|
{
|
|
textarea.value = textarea.value.substring(0, start)
|
|
+ contents
|
|
+ textarea.value.substring(end);
|
|
|
|
return [start, start + contents.length];
|
|
}
|
|
|
|
/* Event handler that inserts specified tokens around the selection.
|
|
after token is the same as before if not specified */
|
|
function editor_insert_around(event, before="", after=null)
|
|
{
|
|
const [editor, ta] = editor_event_source(event);
|
|
ta.focus();
|
|
let indexStart = ta.selectionStart;
|
|
let indexEnd = ta.selectionEnd;
|
|
|
|
if (after === null) {
|
|
after = before;
|
|
}
|
|
|
|
let [start, end] = editor_replace_range(ta, indexStart, indexEnd,
|
|
before + ta.value.substring(indexStart, indexEnd) + after);
|
|
|
|
/* Restore selection */
|
|
if (indexStart != indexEnd) {
|
|
ta.selectionStart = start;
|
|
ta.selectionEnd = end;
|
|
}
|
|
else {
|
|
ta.selectionStart = ta.selectionEnd = start + before.length;
|
|
}
|
|
|
|
preview(editor);
|
|
}
|
|
|
|
/* Event handler that modifies each line within the selection through a
|
|
generic function. */
|
|
function editor_act_on_lines(event, fn)
|
|
{
|
|
const [editor, ta] = editor_event_source(event);
|
|
ta.focus();
|
|
let indexStart = ta.selectionStart;
|
|
let indexEnd = ta.selectionEnd;
|
|
|
|
let firstLineIndex = ta.value.substring(0, indexStart).lastIndexOf('\n');
|
|
if (firstLineIndex < 0)
|
|
firstLineIndex = 0;
|
|
else
|
|
firstLineIndex += 1;
|
|
|
|
let lastLineIndex = ta.value.substring(indexEnd).indexOf('\n');
|
|
if (lastLineIndex < 0)
|
|
lastLineIndex = ta.value.length;
|
|
else
|
|
lastLineIndex += indexEnd;
|
|
|
|
let lines = ta.value.substring(firstLineIndex, lastLineIndex).split('\n');
|
|
|
|
for(let i = 0; i < lines.length; i++)
|
|
lines[i] = fn(lines[i], i);
|
|
|
|
let [start, end] = editor_replace_range(ta, firstLineIndex, lastLineIndex,
|
|
lines.join('\n'));
|
|
|
|
ta.selectionStart = start;
|
|
ta.selectionEnd = end;
|
|
|
|
preview(editor);
|
|
}
|
|
|
|
function editor_clear_modals(event, close = true)
|
|
{
|
|
// Stop the propagation of the event
|
|
event.stopPropagation()
|
|
const [editor, ta] = editor_event_source(event);
|
|
|
|
// Reset all modal inputs
|
|
editor.getElementsByClassName('media-alt-input')[0].value = '';
|
|
editor.getElementsByClassName('media-link-input')[0].value = '';
|
|
editor.getElementsByClassName('link-desc-input')[0].value = '';
|
|
editor.getElementsByClassName('link-link-input')[0].value = '';
|
|
const media_type = editor.getElementsByClassName("media-type")[0];
|
|
for(i = 0; i < media_type.length; i++) {
|
|
media_type[i].checked = false;
|
|
}
|
|
|
|
// Close all modal if requested
|
|
if (!close) { return }
|
|
const modals = editor.getElementsByClassName('modal');
|
|
for (const i of modals) {i.style.display = 'none'};
|
|
}
|
|
|
|
|
|
/* End-user functions */
|
|
function editor_inline(event, type, enable_preview = true)
|
|
{
|
|
tokens = {
|
|
bold: "**",
|
|
italic: "*",
|
|
underline: "__",
|
|
strike: "~~",
|
|
inlinecode: "`",
|
|
};
|
|
|
|
if (type in tokens) {
|
|
editor_insert_around(event, tokens[type]);
|
|
}
|
|
|
|
if (enable_preview) {
|
|
const [editor, ta] = editor_event_source(event);
|
|
preview(editor);
|
|
}
|
|
}
|
|
|
|
function editor_display_link_modal(event) {
|
|
const [editor, ta] = editor_event_source(event);
|
|
let indexStart = ta.selectionStart;
|
|
let indexEnd = ta.selectionEnd;
|
|
let selection = ta.value.substring(indexStart, indexEnd);
|
|
|
|
// Assuming it's a link
|
|
if (selection.match(/^https?:\/\/\S+/)) {
|
|
event.currentTarget.querySelector("#link-link-input").value = selection;
|
|
}
|
|
// Or text
|
|
else if (selection != "") {
|
|
event.currentTarget.querySelector("#link-desc-input").value = selection;
|
|
}
|
|
|
|
editor_display_child_modal(event);
|
|
}
|
|
|
|
function editor_insert_link(event, link_id, text_id, media = false)
|
|
{
|
|
const [editor, ta] = editor_event_source(event);
|
|
ta.focus();
|
|
let indexStart = ta.selectionStart;
|
|
let indexEnd = ta.selectionEnd;
|
|
|
|
const link = editor.getElementsByClassName(link_id)[0].value;
|
|
const text = editor.getElementsByClassName(text_id)[0].value;
|
|
let media_type = "";
|
|
|
|
const media_selector = editor.getElementsByClassName("media-type")[0];
|
|
for(i = 0; i < media_selector.length; i++) {
|
|
if (media_selector[i].checked) {
|
|
media_type = `{type=${media_selector[i].value}}`;
|
|
}
|
|
}
|
|
|
|
editor_clear_modals(event);
|
|
|
|
let [start, end] = editor_replace_range(ta, indexStart, indexEnd,
|
|
`${media ? "!" : ""}[${text.length === 0 ? ta.value.substring(indexStart, indexEnd) : text}](${link})${media ? media_type : ""}`);
|
|
|
|
/* Restore selection */
|
|
if (indexStart != indexEnd) {
|
|
ta.selectionStart = start;
|
|
ta.selectionEnd = end;
|
|
}
|
|
else {
|
|
ta.selectionStart = ta.selectionEnd = start + 1;
|
|
}
|
|
|
|
preview(editor);
|
|
}
|
|
|
|
function editor_title(event, level, diff)
|
|
{
|
|
editor_act_on_lines(event, function(line, _) {
|
|
/* Strip all the initial # (and count them) */
|
|
let count = 0;
|
|
while(count < line.length && line[count] == '#') count++;
|
|
|
|
let contents_index = count;
|
|
if (count < line.length && line[count] == ' ') contents_index++;
|
|
let contents = line.slice(contents_index);
|
|
|
|
if (level > 0 || count == 1 && diff == -1) {
|
|
/* Remove the title if the corresponding level is re-requested */
|
|
if (count == level || count == 1 && diff == -1)
|
|
return contents;
|
|
/* Otherwise, add it */
|
|
else
|
|
return '#'.repeat(level) + ' ' + contents;
|
|
}
|
|
else if (count > 0) {
|
|
/* Apply the difference */
|
|
let new_level = Math.max(1, Math.min(6, count + diff));
|
|
return '#'.repeat(new_level) + ' ' + contents;
|
|
}
|
|
return line;
|
|
});
|
|
}
|
|
|
|
function editor_quote(event)
|
|
{
|
|
editor_act_on_lines(event, function(line, _) {
|
|
/* Strip all the initial > (and count them) */
|
|
let count = 0;
|
|
while(count < line.length && line[count] == '>') count++;
|
|
|
|
let contents_index = count;
|
|
if (count < line.length && line[count] == ' ') contents_index++;
|
|
let contents = line.slice(contents_index);
|
|
|
|
/* Apply the difference */
|
|
return '>'.repeat(count + 1) + ' ' + contents;
|
|
});
|
|
}
|
|
|
|
function editor_bullet_list(event)
|
|
{
|
|
editor_act_on_lines(event, function(line, _) {
|
|
let ident_match = line.match(/^[\t]+/m) ?? [''];
|
|
let ident = ident_match[0];
|
|
let count = ident.length;
|
|
|
|
const contents = line.slice(count);
|
|
if ((count < line.length || count == 0) && line[count] != '-') return '- ' + contents;
|
|
|
|
return ident + "\t" + contents;
|
|
});
|
|
}
|
|
|
|
function editor_numbered_list(event)
|
|
{
|
|
editor_act_on_lines(event, function(line, number) {
|
|
let ident_match = line.match(/^[\t]+/m) ?? [''];
|
|
let ident = ident_match[0];
|
|
let count = ident.length;
|
|
|
|
const contents = line.slice(count);
|
|
if ((count < line.length || count == 0) && isNaN(line[count])) return `${number + 1}. ` + contents;
|
|
|
|
return ident + "\t" + contents;
|
|
});
|
|
}
|
|
|
|
function editor_table(event) {
|
|
let table = `| Column 1 | Column 2 | Column 3 |
|
|
| -------- | -------- | -------- |
|
|
| Text | Text | Text |`;
|
|
|
|
editor_insert_around(event, "", table);
|
|
}
|
|
|
|
function editor_separator(event) {
|
|
editor_insert_around(event, "", "\n---\n");
|
|
}
|
|
|
|
function editor_display_child_modal(event) {
|
|
editor_clear_modals(event);
|
|
event.currentTarget.children[1].style = {'display': 'block'};
|
|
}
|
|
|
|
const DISABLE_PREVIEW_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-slash" viewBox="0 0 16 16"><path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/><path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/><path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/></svg>';
|
|
const ENABLE_PREVIEW_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16"><path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/><path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/></svg>';
|
|
|
|
function toggle_auto_preview() {
|
|
const [editor, ta] = editor_event_source(event);
|
|
|
|
let auto_preview;
|
|
if (document.cookie.split(";").some((item) => item.trim().startsWith("auto-preview="))) {
|
|
auto_preview = document.cookie.split(";").some((item) => item.includes("auto-preview=true"));
|
|
} else {
|
|
auto_preview = true;
|
|
}
|
|
document.cookie = `auto-preview=${!auto_preview}; max-age=31536000; SameSite=Strict; Secure`
|
|
if (!auto_preview) {
|
|
editor.getElementsByClassName("toggle_preview")[0].title = "Désactiver la prévisualisation";
|
|
editor.getElementsByClassName("toggle_preview")[0].innerHTML = DISABLE_PREVIEW_ICON;
|
|
editor.getElementsByClassName("manual_preview")[0].style = "display: none";
|
|
} else {
|
|
editor.getElementsByClassName("toggle_preview")[0].title = "Activer la prévisualisation";
|
|
editor.getElementsByClassName("toggle_preview")[0].innerHTML = ENABLE_PREVIEW_ICON;
|
|
editor.getElementsByClassName("manual_preview")[0].style = "display: block";
|
|
}
|
|
}
|
|
|
|
/* This request the server to get a complete render of the current text in the textarea */
|
|
function preview(editor, manual=false) {
|
|
// If auto-preview is disabled and the preview is not manually requested by the user
|
|
if (document.cookie.split(";").some((item) => item.includes("auto-preview=false")) && !manual) {
|
|
return;
|
|
}
|
|
const previewArea = editor.querySelector(".editor_content_preview");
|
|
const ta = editor.querySelector("textarea");
|
|
const payload = {text: ta.value};
|
|
|
|
const headers = new Headers();
|
|
headers.append("Content-Type", "application/json");
|
|
|
|
const params = {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
headers
|
|
};
|
|
|
|
fetch("/api/markdown", params).then(
|
|
(response) => {
|
|
response.text().then(
|
|
(text) => {
|
|
previewArea.innerHTML = text;
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/* Wrapper for user-requested preview refresh */
|
|
function manual_preview(editor_id) {
|
|
const editor = document.getElementById("editor_" + editor_id);
|
|
preview(editor, manual = true);
|
|
}
|
|
|
|
/* Add the event listener for the textarea hotkeys and auto-preview */
|
|
function editor_setup(editor_id) {
|
|
const editor = document.getElementById("editor_" + editor_id);
|
|
|
|
if (document.cookie.split(";").some((item) => item.trim().startsWith("auto-preview="))) {
|
|
if (document.cookie.split(";").some((item) => item.includes("auto-preview=false"))) {
|
|
editor.getElementsByClassName("toggle_preview")[0].title = "Activer la prévisualisation";
|
|
editor.getElementsByClassName("toggle_preview")[0].innerHTML = ENABLE_PREVIEW_ICON;
|
|
editor.getElementsByClassName("manual_preview")[0].style = "display: block";
|
|
}
|
|
}
|
|
|
|
let previewTimeout = null;
|
|
let ta = editor.querySelector(".editor textarea");
|
|
ta.addEventListener('keydown', function(e) {
|
|
// Tab insert some spaces
|
|
let keyCode = e.keyCode || e.which;
|
|
if (keyCode == 9) {
|
|
// TODO Add one tab to selected text without replacing it
|
|
e.preventDefault();
|
|
|
|
let start = e.target.selectionStart;
|
|
let end = e.target.selectionEnd;
|
|
// set textarea value to: text before caret + tab + text after caret
|
|
e.target.value = e.target.value.substring(0, start) + "\t" + e.target.value.substring(end);
|
|
e.target.selectionEnd = start + 1;
|
|
}
|
|
|
|
/*
|
|
* Keybindings for buttons. The default action of the keybinding is prevented.
|
|
* Ctrl+B adds bold
|
|
* Ctrl+I adds italic
|
|
* Ctrl+U adds underline
|
|
* Ctrl+S adds strikethrough
|
|
* Ctrl+H adds Header +1
|
|
* Ctrl+Enter send the form
|
|
*/
|
|
if (e.ctrlKey) {
|
|
switch (keyCode) {
|
|
case 13:
|
|
let t = e.target;
|
|
while(! (t instanceof HTMLFormElement)) {
|
|
t = t.parentNode;
|
|
}
|
|
try {
|
|
t.submit();
|
|
} catch(exception) {
|
|
t.submit.click();
|
|
}
|
|
e.preventDefault();
|
|
break;
|
|
case 66: // B
|
|
editor_inline(e, "bold", false);
|
|
e.preventDefault();
|
|
break;
|
|
case 72: // H
|
|
editor_title(e, 0, +1);
|
|
e.preventDefault();
|
|
break;
|
|
case 73: // I
|
|
editor_inline(e, "italic", false);
|
|
e.preventDefault();
|
|
break;
|
|
case 83: // S
|
|
editor_inline(e, "strike", false);
|
|
e.preventDefault();
|
|
break;
|
|
case 85: // U
|
|
editor_inline(e, "underline", false);
|
|
e.preventDefault();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Set a timeout for refreshing the preview
|
|
clearTimeout(previewTimeout);
|
|
previewTimeout = setTimeout(() => { preview(editor) }, 3000);
|
|
});
|
|
|
|
editor.querySelector('emoji-picker').addEventListener('emoji-click', event => {
|
|
editor_clear_modals(event);
|
|
editor_insert_around(event, "", event.detail.unicode)
|
|
|
|
preview(editor);
|
|
});
|
|
}
|