checkpoint: working markdown<=>html converter
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='editor/editor.css' ) }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id='{% if user.can_write(page) %}editor{% else %}viewer{% endif %}' class='read-only'>
|
||||
{{ page.body }}
|
||||
</div>
|
||||
<div id='content'>{{ page.body }}</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='editor/commonmark.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/turndown.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/turndown-plugin-gfm.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/editor.js' ) }}"></script>
|
||||
<!-- for converting markdown to html -->
|
||||
<script src="{{ url_for('static', filename='editor/purify.min.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/marked.umd.min.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/grung.js' ) }}"></script>
|
||||
{% if user.can_write(page) %}
|
||||
<script src="{{ url_for('static', filename='editor/turndown.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/joplin-turndown-plugin-gfm.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/grung-editor.js' ) }}"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
const wiki = new Grung{% if user.can_write(page) %}Editor{% endif %}({plugins: [MacroPlugin]});
|
||||
wiki.view();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,343 +0,0 @@
|
||||
class Editor {
|
||||
|
||||
#states = {
|
||||
VIEW: 'view',
|
||||
EDIT: 'edit',
|
||||
WYSIWYG: 'wysiwyg'
|
||||
}
|
||||
|
||||
commonmark = null;
|
||||
turndown = null;
|
||||
#cachedHTML = null;
|
||||
#cachedMarkdown = null;
|
||||
#state = null;
|
||||
#changed = false;
|
||||
|
||||
constructor(settings) {
|
||||
/*
|
||||
* Create a new Editor instance.
|
||||
*/
|
||||
this.element = document.getElementById(settings.editorId || 'editor');
|
||||
this.source = this.element.innerHTML;
|
||||
this.commonmark = {
|
||||
reader: new commonmark.Parser(),
|
||||
writer: new commonmark.HtmlRenderer(),
|
||||
};
|
||||
this.turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
});
|
||||
|
||||
this.plugins = {};
|
||||
settings.plugins.forEach(plugin => {
|
||||
this.plugins[plugin.name] = new plugin({name: plugin.name, editor: this});
|
||||
});
|
||||
this.#bindEvents();
|
||||
this.getHTML();
|
||||
this.element.classList.add("loaded");
|
||||
}
|
||||
|
||||
#bindEvents() {
|
||||
this.element.addEventListener('keydown', (evt) => {
|
||||
if (this.#state === this.#states.VIEW) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (this.#state === this.#states.EDIT) {
|
||||
evt.preventDefault();
|
||||
this.insertAtCursor(document.createTextNode("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#cachedMarkdown != this.element.innerHTML) {
|
||||
this.#changed = true;
|
||||
this.#cachedMarkdown = this.element.innerHTML;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
getState() {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
setState(newState) {
|
||||
this.#state = newState;
|
||||
Object.values(this.#states).forEach(state => {
|
||||
if (state == newState) {
|
||||
this.element.classList.add(state);
|
||||
} else {
|
||||
this.element.classList.remove(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getHTML() {
|
||||
/*
|
||||
* Convert the markdown source to HTML.
|
||||
*/
|
||||
if (this.#changed || !this.#cachedHTML) {
|
||||
var md = this.getMarkdown();
|
||||
var parsed = this.commonmark.reader.parse(md);
|
||||
var html = this.commonmark.writer.render(parsed);
|
||||
Object.values(this.plugins).forEach(plugin => {
|
||||
html = plugin.toHTML(html);
|
||||
});
|
||||
this.#cachedHTML = html;
|
||||
|
||||
}
|
||||
return this.#cachedHTML;
|
||||
}
|
||||
|
||||
getMarkdown() {
|
||||
/*
|
||||
* Return the current markdown.
|
||||
*/
|
||||
if (this.getState() === this.#states.EDIT) {
|
||||
this.#cachedMarkdown = this.element.innerHTML;
|
||||
} else if (this.getState() === this.#states.WYSIWYG) {
|
||||
var md = this.element.innerHTML;
|
||||
Object.values(this.plugins).forEach(plugin => {
|
||||
md = plugin.toMarkdown(md);
|
||||
});
|
||||
this.#cachedMarkdown = this.turndown.turndown(md);
|
||||
} else if (!this.#cachedMarkdown) {
|
||||
this.#cachedMarkdown = this.source;
|
||||
}
|
||||
this.#cachedMarkdown = this.#cachedMarkdown.replaceAll(/^>/mg, '>');
|
||||
return this.#cachedMarkdown;
|
||||
}
|
||||
|
||||
reset() {
|
||||
/*
|
||||
* Discard any unsaved edits and reset the editor to its initial state.
|
||||
*/
|
||||
this.#cachedHTML = null;
|
||||
this.#cachedMarkdown = null;
|
||||
this.view();
|
||||
}
|
||||
|
||||
view() {
|
||||
/*
|
||||
* Convert the editor read-only mode and display the current HTML.
|
||||
*/
|
||||
if (this.getState() === this.#states.VIEW) {
|
||||
return;
|
||||
}
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.#states.VIEW);
|
||||
}
|
||||
|
||||
wysiwyg() {
|
||||
/*
|
||||
* Put the editor in WYSIWYG editing mode.
|
||||
*/
|
||||
if (this.getState() === this.#states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.#changed = false;
|
||||
this.element.contentEditable = true;
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.#states.WYSIWYG);
|
||||
}
|
||||
|
||||
edit() {
|
||||
/*
|
||||
* Put the editor into source editing mode.
|
||||
*/
|
||||
if (this.#state === this.#states.EDIT) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#changed = false;
|
||||
this.element.contentEditable = true;
|
||||
this.element.innerHTML = this.getMarkdown();
|
||||
this.setState(this.#states.EDIT);
|
||||
}
|
||||
|
||||
insertAtCursor(node) {
|
||||
var sel, range, html;
|
||||
sel = window.getSelection();
|
||||
range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(node);
|
||||
range.setStartAfter(node);
|
||||
this.element.focus();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class EditorPlugin {
|
||||
constructor(settings) {
|
||||
this.name = settings.name;
|
||||
this.editor = settings.editor;
|
||||
};
|
||||
|
||||
cleanAttribute(attribute) {
|
||||
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : ''
|
||||
}
|
||||
|
||||
toMarkdown(html) {
|
||||
return html;
|
||||
};
|
||||
|
||||
toHTML(md) {
|
||||
return md;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
class TablePlugin extends EditorPlugin {
|
||||
constructor(settings) {
|
||||
super(settings);
|
||||
this.editor.turndown.use(turndownPluginGfm.tables);
|
||||
this.pattern = /(?<contents>(?:\|[^|]+?)+\|[\s\n]*)(?:<|$)/gims;
|
||||
this.dividerPattern = /\|\s*\-+\s*\|/;
|
||||
}
|
||||
|
||||
toHTML(md) {
|
||||
md.matchAll(this.pattern).forEach(matched => {
|
||||
var html = '<table><thead>';
|
||||
var cellTag = 'th';
|
||||
matched.groups.contents.split("\n").forEach(line => {
|
||||
if (this.dividerPattern.test(line)) {
|
||||
html += "</thead><tbody>";
|
||||
cellTag = 'td';
|
||||
} else {
|
||||
html += "<tr>";
|
||||
var cells = line.split("|");
|
||||
html += "<" + cellTag + ">" + cells.slice(1, cells.length - 1).join("</" + cellTag + "><" + cellTag + ">") + "</" + cellTag + ">";
|
||||
html += "</tr>";
|
||||
}
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
md = md.replaceAll(matched[1], html);
|
||||
});
|
||||
return md;
|
||||
}
|
||||
}
|
||||
|
||||
class MacroPlugin extends EditorPlugin {
|
||||
|
||||
macros = {
|
||||
// image: {}
|
||||
// toc {}
|
||||
// widget {}
|
||||
//
|
||||
html: {
|
||||
toHTML: (settings) => {
|
||||
var html = settings.block.replaceAll(">", ">").replaceAll("<", "<");
|
||||
return html;
|
||||
},
|
||||
toMarkdown: (node) => {
|
||||
const utf8encoder = new TextEncoder();
|
||||
return "{{html\n" + b64decode(node.dataset.block) + "\n}}";
|
||||
}
|
||||
},
|
||||
user: {
|
||||
toHTML: (settings) => {
|
||||
return document.querySelector("nav > ul > li.user > a:first-child").outerHTML;
|
||||
},
|
||||
},
|
||||
npc: {
|
||||
toHTML: (settings) => {
|
||||
var name = camelCase(settings.keywords).join(" ");
|
||||
var target = name.replaceAll(" ", "");
|
||||
return `<a href="/NPC/${target}" data-npc-name="${name}">👤 ${name}</a>`;
|
||||
},
|
||||
toMarkdown: (node) => {
|
||||
return `{{npc ${node.firstChild.dataset.npcName}}}`;
|
||||
},
|
||||
},
|
||||
spell: {
|
||||
toHTML: (settings) => {
|
||||
var name = camelCase(settings.keywords).join(" ");
|
||||
var target = name.replaceAll(" ", "");
|
||||
return `<a href="/Spell/${target}" data-spell-name="${name}">✨ ${name}</a>`;
|
||||
},
|
||||
toMarkdown: (node) => {
|
||||
return `{{spell ${node.firstChild.dataset.spellName}}}`;
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
constructor(settings) {
|
||||
super(settings);
|
||||
this.pattern = /(?<contents>{{(?<name>\w+)(?<keywords>(?:\s*\w+)*)(?<parameters>(?:\s+\w+="[^"]*")*?)(?<block>\b.*?)?}})/gms;
|
||||
this.paramPattern = /\b(?<name>[^=]+)="(?<value>[^"]*?)"\b/gm;
|
||||
|
||||
var plugin = this;
|
||||
this.editor.turndown.addRule('macros', {
|
||||
filter: ['span'],
|
||||
replacement: function (content, node, options) {
|
||||
const parser = plugin.macros[node.getAttribute('data-macro-name')].toMarkdown;
|
||||
if (parser) {
|
||||
return parser(node);
|
||||
}
|
||||
|
||||
var md = '{{' + node.getAttribute('data-macro-name');
|
||||
for (var param in node.getAttributeNames()) {
|
||||
var val = node.getAttribute(param);
|
||||
if (val) {
|
||||
md += val + (param == 'data-macro-name' ? " " : `="${node.getAttribute(param)}" `);
|
||||
}
|
||||
}
|
||||
md += '}}';
|
||||
return md;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toHTML(md) {
|
||||
const utf8encoder = new TextEncoder();
|
||||
var output = md;
|
||||
output.matchAll(this.pattern).forEach(matched => {
|
||||
var macroName = matched.groups.name;
|
||||
var html = `<span class='macro' data-macro-name='${macroName}'`;
|
||||
var settings = {
|
||||
block: matched.groups.block,
|
||||
keywords: matched.groups.keywords,
|
||||
params: {},
|
||||
}
|
||||
matched.groups.parameters.matchAll(this.paramPattern).forEach(param => {
|
||||
settings.params[param.groups.name] = param.groups.value;
|
||||
html += ` data-param-${param.groups.name}="${param.groups.value}"`;
|
||||
});
|
||||
html += ` data-block="${b64encode(matched.groups.block)}"`;
|
||||
html += '>';
|
||||
html += this.macros[macroName].toHTML(settings);
|
||||
html += '</span>';
|
||||
output = output.replaceAll(matched[1], html);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
function camelCase(words) {
|
||||
var output = [];
|
||||
words.trim().split(/\s+/g).forEach(word => {
|
||||
var lcWord = word.toLowerCase();
|
||||
output.push(lcWord.charAt(0).toUpperCase() + lcWord.slice(1));
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function b64encode(input) {
|
||||
const utf8encoder = new TextEncoder();
|
||||
return utf8encoder.encode(input).toBase64();
|
||||
}
|
||||
|
||||
function b64decode(input) {
|
||||
const utf8encoder = new TextEncoder();
|
||||
return utf8encoder.decode(input.fromBase64());
|
||||
}
|
||||
|
||||
|
||||
editor = new Editor({
|
||||
plugins: [TablePlugin, MacroPlugin]
|
||||
});
|
||||
editor.view();
|
||||
@@ -0,0 +1,103 @@
|
||||
class GrungEditor extends Grung {
|
||||
|
||||
constructor(settings) {
|
||||
/*
|
||||
* Create a new Editor instance.
|
||||
*/
|
||||
super(settings);
|
||||
this.states = {
|
||||
VIEW: 'view',
|
||||
EDIT: 'edit',
|
||||
WYSIWYG: 'wysiwyg'
|
||||
}
|
||||
|
||||
this.turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
});
|
||||
this.turndown.use([turndownPluginGfm.gfm, turndownPluginGfm.tables]);
|
||||
this.turndown.keep(['pre']);
|
||||
this.#bindEvents();
|
||||
|
||||
this.plugins().forEach(plugin => { plugin.setEditable() });
|
||||
}
|
||||
|
||||
#bindEvents() {
|
||||
this.element.addEventListener('keydown', (evt) => {
|
||||
if (this.state === this.states.VIEW) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
if (event.key === 'Enter') {
|
||||
console.log(this.#state, this.#states.EDIT);
|
||||
if (this.#state === this.#states.EDIT) {
|
||||
evt.preventDefault();
|
||||
this.insertAtCursor(document.createTextNode("\n"));
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
if (this.cachedMarkdown != this.element.textContent) {
|
||||
this.changed = true;
|
||||
this.cachedMarkdown = this.element.textContent;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
HtmlToMarkdown(html) {
|
||||
return this.turndown.turndown(html);
|
||||
}
|
||||
|
||||
getMarkdown() {
|
||||
/*
|
||||
* Return the current markdown.
|
||||
*/
|
||||
if (this.getState() === this.states.EDIT) {
|
||||
this.cachedMarkdown = this.element.innerHTML.replaceAll(/<\/?div>/g, "\n").replaceAll('<br>', "");
|
||||
} else if (this.getState() === this.states.WYSIWYG) {
|
||||
this.cachedMarkdown = this.HtmlToMarkdown(this.element.innerHTML);
|
||||
} else if (!this.cachedMarkdown) {
|
||||
this.cachedMarkdown = this.source;
|
||||
}
|
||||
return this.cachedMarkdown;
|
||||
}
|
||||
|
||||
wysiwyg() {
|
||||
/*
|
||||
* Put the editor in WYSIWYG editing mode.
|
||||
*/
|
||||
if (this.getState() === this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.changed = false;
|
||||
this.element.contentEditable = true;
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.states.WYSIWYG);
|
||||
}
|
||||
|
||||
edit() {
|
||||
/*
|
||||
* Put the editor into source editing mode.
|
||||
*/
|
||||
if (this.state === this.states.EDIT) {
|
||||
return;
|
||||
}
|
||||
this.changed = false;
|
||||
this.element.contentEditable = true;
|
||||
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
||||
this.setState(this.states.EDIT);
|
||||
}
|
||||
|
||||
insertAtCursor(node) {
|
||||
var sel, range, html;
|
||||
sel = window.getSelection();
|
||||
range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(node);
|
||||
range.setStartAfter(node);
|
||||
this.element.focus();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
class Grung {
|
||||
constructor(settings) {
|
||||
/*
|
||||
* Create a new Editor instance.
|
||||
*/
|
||||
this.element = document.getElementById(settings.editorId || 'content');
|
||||
this.source = this.element.textContent;
|
||||
|
||||
this.marked = marked;
|
||||
this.marked.use({
|
||||
breaks: false,
|
||||
gfm: true,
|
||||
});
|
||||
this.states = {
|
||||
VIEW: 'view',
|
||||
}
|
||||
this.cachedHTML = null;
|
||||
this.cachedMarkdown = null;
|
||||
this.state = null;
|
||||
this.changed = false;
|
||||
this.enabledPlugins = {};
|
||||
|
||||
settings.plugins.forEach(plugin => {
|
||||
this.enabledPlugins[plugin.name] = new plugin({name: plugin.name, editor: this});
|
||||
});
|
||||
this.getHTML();
|
||||
this.element.classList.add("loaded");
|
||||
}
|
||||
|
||||
plugins() {
|
||||
return Object.values(this.enabledPlugins).sort((a, b) => { a.precedence < b.precedence });
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
setState(newState) {
|
||||
this.state = newState;
|
||||
Object.values(this.states).forEach(state => {
|
||||
if (state == newState) {
|
||||
this.element.classList.add(state);
|
||||
} else {
|
||||
this.element.classList.remove(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
markdownToHTML(md) {
|
||||
var html = this.marked.parse(md);
|
||||
return html;
|
||||
}
|
||||
|
||||
getHTML(string) {
|
||||
/*
|
||||
* Convert the markdown source to HTML.
|
||||
*/
|
||||
if (this.changed || !this.cachedHTML) {
|
||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
||||
}
|
||||
return this.cachedHTML;
|
||||
}
|
||||
|
||||
getMarkdown() {
|
||||
if (!this.cachedMarkdown) {
|
||||
this.cachedMarkdown = this.source;
|
||||
}
|
||||
return this.cachedMarkdown;
|
||||
}
|
||||
|
||||
reset() {
|
||||
/*
|
||||
* Discard any unsaved edits and reset the editor to its initial state.
|
||||
*/
|
||||
this.cachedHTML = null;
|
||||
this.cachedMarkdown = null;
|
||||
this.view();
|
||||
}
|
||||
|
||||
view() {
|
||||
/*
|
||||
* Convert the editor read-only mode and display the current HTML.
|
||||
*/
|
||||
if (this.getState() === this.states.VIEW) {
|
||||
return;
|
||||
}
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.states.VIEW);
|
||||
this.contentEditable = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class GrungPlugin {
|
||||
|
||||
constructor(settings) {
|
||||
this.name = settings.name;
|
||||
this.editor = settings.editor;
|
||||
this.precedence = 50;
|
||||
};
|
||||
|
||||
setEditable() {
|
||||
};
|
||||
|
||||
toMarkdown(html) {
|
||||
return html;
|
||||
};
|
||||
|
||||
toHTML(md) {
|
||||
return md;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
class MacroPlugin extends GrungPlugin {
|
||||
|
||||
macros = {
|
||||
// image: {}
|
||||
// toc {}
|
||||
// widget {}
|
||||
//
|
||||
style: {
|
||||
inline: false,
|
||||
toHTML: (token, node) => {
|
||||
return node.replace(/class="macro"/, `class="macro ${token.keywords}"`);
|
||||
}
|
||||
},
|
||||
|
||||
multiline: {
|
||||
inline: false,
|
||||
fromHTML: (node, markdown) => {
|
||||
var content = node.innerHTML.replaceAll("<br>", "\0");
|
||||
content = content.replaceAll("{{{", "{{{");
|
||||
content = content.replaceAll("}}}", "}}}");
|
||||
content = encodeHtmlEntities(content);
|
||||
return `{{{${content}}}}`;
|
||||
},
|
||||
},
|
||||
|
||||
user: {
|
||||
inline: true,
|
||||
toHTML: (token, node) => {
|
||||
return node + document.querySelector("nav > ul > li.user > a:first-child").outerHTML;
|
||||
},
|
||||
},
|
||||
toc: {
|
||||
inline: true,
|
||||
element: 'div',
|
||||
toHTML: (token, node) => {
|
||||
return node + "</div>";
|
||||
},
|
||||
postprocess: (html) => {
|
||||
const subList = (depth) => {
|
||||
var li = document.createElement("li");
|
||||
var ul = document.createElement("ul");
|
||||
li.appendChild(ul);
|
||||
return li;
|
||||
};
|
||||
|
||||
const buf = document.createElement('div');
|
||||
buf.innerHTML = html;
|
||||
const tocElement =
|
||||
buf.querySelectorAll('[data-macro-name="toc"]').forEach(tocElement => {
|
||||
|
||||
var params = {
|
||||
depth: tocElement.dataset.paramDepth || 3,
|
||||
keywords: tocElement.dataset.paramKeywords || "",
|
||||
};
|
||||
|
||||
const headings = buf.querySelectorAll("h2, h3, h4, h5, h6");
|
||||
|
||||
const toc = document.createElement("ul");
|
||||
toc.setAttribute('role', 'list');
|
||||
|
||||
var lastDepth = null;
|
||||
var ul = toc;
|
||||
headings.forEach(heading => {
|
||||
var depth = parseInt(heading.nodeName[1]) - 1;
|
||||
if (depth > params.depth) {
|
||||
return;
|
||||
}
|
||||
if (lastDepth === null) {
|
||||
lastDepth = depth;
|
||||
}
|
||||
|
||||
var index = document.createElement("li");
|
||||
index.innerHTML = '<a href="#h">' + heading.innerHTML + "</a>";
|
||||
|
||||
var list = null;
|
||||
if (depth > lastDepth) {
|
||||
list = subList(depth);
|
||||
ul.appendChild(list);
|
||||
ul = list.firstChild;
|
||||
} else if (depth < lastDepth) {
|
||||
var list = subList(depth);
|
||||
toc.appendChild(list);
|
||||
ul = list.firstChild;
|
||||
}
|
||||
ul.appendChild(index);
|
||||
lastDepth = depth;
|
||||
});
|
||||
|
||||
if (params.keywords) {
|
||||
params.keywords.split(" ").forEach(className => {
|
||||
if (className) {
|
||||
toc.classList.add(className);
|
||||
}
|
||||
});
|
||||
}
|
||||
tocElement.appendChild(toc);
|
||||
});
|
||||
return buf.innerHTML;
|
||||
},
|
||||
},
|
||||
npc: {
|
||||
toHTML: (settings) => {
|
||||
var name = camelCase(settings.keywords).join(" ");
|
||||
var target = name.replaceAll(" ", "");
|
||||
return `<a href="/NPC/${target}" data-npc-name="${name}">👤 ${name}</a>`;
|
||||
},
|
||||
fromHTML: (node) => {
|
||||
return `{{npc ${node.firstChild.dataset.npcName}}}`;
|
||||
},
|
||||
},
|
||||
spell: {
|
||||
toHTML: (settings) => {
|
||||
var name = camelCase(settings.keywords).join(" ");
|
||||
var target = name.replaceAll(" ", "");
|
||||
return `<a href="/Spell/${target}" data-spell-name="${name}">✨ ${name}</a>`;
|
||||
},
|
||||
fromHTML: (node) => {
|
||||
return `{{spell ${node.firstChild.dataset.spellName}}}`;
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
getTokens = (pattern, source) => {
|
||||
const matched = source.matchAll(pattern);
|
||||
const tokens = [];
|
||||
if (!matched) {
|
||||
return tokens;
|
||||
}
|
||||
matched.forEach(match => {
|
||||
if (!this.macros[match.groups.name]) {
|
||||
return;
|
||||
}
|
||||
const token = {
|
||||
type: 'macro',
|
||||
source: match[0],
|
||||
matched: match,
|
||||
macro: this.macros[match.groups.name],
|
||||
keywords: (match.groups.keywords || '').trim(),
|
||||
inline: this.macros[match.groups.name].inline,
|
||||
params: {},
|
||||
rendered: '',
|
||||
};
|
||||
if (match.groups.parameters) {
|
||||
var params = decodeHtmlEntities(match.groups.parameters.trim());
|
||||
params.matchAll(this.paramPattern).forEach(param => {
|
||||
if (param.groups) {
|
||||
var name = param.groups.name;
|
||||
token.params[name] = decodeHtmlEntities(param.groups.value.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
token.rendered = this.renderToken(token);
|
||||
tokens.push(token);
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
renderToken = (token) => {
|
||||
const tag = token.macro.element || (token.inline ? 'span' : 'div');
|
||||
var node = `<${tag} class="macro" data-plugin-name="macro" data-macro-name="${token.matched.groups.name}"`;
|
||||
|
||||
for (var name in token.params) {
|
||||
node += ` data-param-${name}="${(token.params[name] || "").trim()}"`;
|
||||
}
|
||||
|
||||
if (token.keywords) {
|
||||
node += ` data-keywords="${token.keywords}"`;
|
||||
}
|
||||
|
||||
node += ` data-inline="${token.inline}"`;
|
||||
|
||||
node += ">";
|
||||
|
||||
if (token.macro.toHTML) {
|
||||
node = token.macro.toHTML(token, node);
|
||||
}
|
||||
|
||||
if (token.inline) {
|
||||
node += `</${tag}>`;
|
||||
}
|
||||
|
||||
// preserve the wrapping element, unless it is a paragraph.
|
||||
if (token.matched.groups.wrap) {
|
||||
if (token.matched.groups.wrap != '<p>') {
|
||||
node = token.matched.groups.wrap + node + token.matched.groups.endwrap;
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
|
||||
constructor(settings) {
|
||||
super(settings);
|
||||
this.pattern = /(?<!`)(?<wrap><[^>]+?>){{(?<name>\w+)(?<keywords>(?:\s*[\w-]+)*?)?(?<parameters>(?:\s+[\w-]+=\S+?)*)?\s*(?<closed>}})?(?<endwrap><\/[^>]+?>)/mg;
|
||||
this.endPattern = /<p>}}\s*<\/p>/mg;
|
||||
this.paramPattern = /\s*(?<name>[^=]+)="(?<value>[^"]*)"/g;
|
||||
this.multilinePattern = /(?<!\{{3}[\s\n]*)\{{3}[\s\n]*(?<content>(.(?!\}{3})*)+?)[\s\n]*\}{3}/smg;
|
||||
|
||||
const plugin = this;
|
||||
|
||||
this.editor.marked.use({
|
||||
hooks: {
|
||||
preprocess: (source) => {
|
||||
const matched = source.matchAll(plugin.multilinePattern);
|
||||
var md = source;
|
||||
matched.forEach(match => {
|
||||
var wrapper = '<span class="macro" data-plugin-name="macro" data-macro-name="multiline" data-inline="true" style="display: inline-block;">';
|
||||
var content = decodeHtmlEntities(match.groups.content)
|
||||
.replaceAll("\n", "::BR::")
|
||||
.replaceAll('`', '::QU::')
|
||||
.replaceAll('{', '::OC::')
|
||||
.replaceAll('}', '::CC::');
|
||||
md = md.replaceAll(match[0], wrapper + '\0' + content + '\0</span>');
|
||||
});
|
||||
return md;
|
||||
},
|
||||
postprocess: (html) => {
|
||||
plugin.getTokens(plugin.pattern, html).forEach(token => {
|
||||
html = html.replaceAll(token.source, token.rendered);
|
||||
});
|
||||
html = html.replaceAll(plugin.endPattern, '</div>');
|
||||
Object.values(plugin.macros).forEach(macro => {
|
||||
if (macro.postprocess) {
|
||||
html = macro.postprocess(html);
|
||||
}
|
||||
});
|
||||
|
||||
html = html.replaceAll("::BR::", "<br>")
|
||||
.replaceAll('::QU::', '`')
|
||||
.replaceAll('::OC::', '{')
|
||||
.replaceAll('::CC::', '}');
|
||||
|
||||
// remove unsafe html tags
|
||||
return DOMPurify.sanitize(html, {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setEditable() {
|
||||
this.editor.turndown.addRule('macros', {
|
||||
filter: function (node, options) {
|
||||
return ((node.nodeName === 'DIV' || node.nodeName === 'SPAN') && node.dataset.pluginName == 'macro')
|
||||
},
|
||||
replacement: function (content, node, options) {
|
||||
var macro = plugin.macros[node.getAttribute('data-macro-name')];
|
||||
|
||||
if (macro.fromHTML) {
|
||||
return macro.fromHTML(node, content);
|
||||
}
|
||||
|
||||
var md = '{{' + node.dataset.macroName;
|
||||
if (node.dataset.keywords) {
|
||||
md += " " + node.dataset.keywords;
|
||||
}
|
||||
|
||||
for (var paramName in node.dataset) {
|
||||
if (paramName.indexOf("param") != 0) {
|
||||
continue;
|
||||
}
|
||||
md += ` ${paramName.replace('param', '').toLowerCase()}="${node.dataset[paramName]}"`
|
||||
};
|
||||
|
||||
if (node.dataset.inline == "false") {
|
||||
md = `\n\n${md}\n\n`;
|
||||
md += plugin.editor.HtmlToMarkdown(node.innerHTML);
|
||||
md += "\n\n}}\n\n";
|
||||
} else {
|
||||
md += "}}";
|
||||
}
|
||||
|
||||
// replace nulls with line breaks, for the multiline macro
|
||||
md = md.replaceAll('\0', "\n");
|
||||
|
||||
return md;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function camelCase(words) {
|
||||
var output = [];
|
||||
words.trim().split(/\s+/g).forEach(word => {
|
||||
var lcWord = word.toLowerCase();
|
||||
output.push(lcWord.charAt(0).toUpperCase() + lcWord.slice(1));
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function b64encode(input) {
|
||||
return new TextEncoder().encode(input).toBase64();
|
||||
}
|
||||
|
||||
function b64decode(input) {
|
||||
return new TextDecoder().decode(Uint8Array.fromBase64(input));
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(html) {
|
||||
var txt = document.createElement("textarea");
|
||||
txt.innerHTML = html;
|
||||
return txt.value;
|
||||
}
|
||||
|
||||
function encodeHtmlEntities(str) {
|
||||
return str.replace(/[\u00A0-\u9999<>\&]/g, i => '&#'+i.charCodeAt(0)+';')
|
||||
}
|
||||
+90
-13
@@ -43,6 +43,7 @@ var rules = {};
|
||||
rules.tableCell = {
|
||||
filter: ['th', 'td'],
|
||||
replacement: function (content, node) {
|
||||
if (tableShouldBeSkipped(nodeParentTable(node))) return content;
|
||||
return cell(content, node)
|
||||
}
|
||||
};
|
||||
@@ -50,19 +51,26 @@ rules.tableCell = {
|
||||
rules.tableRow = {
|
||||
filter: 'tr',
|
||||
replacement: function (content, node) {
|
||||
const parentTable = nodeParentTable(node);
|
||||
if (tableShouldBeSkipped(parentTable)) return content;
|
||||
|
||||
var borderCells = '';
|
||||
var alignMap = { left: ':--', right: '--:', center: ':-:' };
|
||||
|
||||
if (isHeadingRow(node)) {
|
||||
for (var i = 0; i < node.childNodes.length; i++) {
|
||||
const colCount = tableColCount(parentTable);
|
||||
for (var i = 0; i < colCount; i++) {
|
||||
const childNode = colCount >= node.childNodes.length ? null : node.childNodes[i];
|
||||
var border = '---';
|
||||
var align = (
|
||||
node.childNodes[i].getAttribute('align') || ''
|
||||
).toLowerCase();
|
||||
var align = childNode ? (childNode.getAttribute('align') || '').toLowerCase() : '';
|
||||
|
||||
if (align) border = alignMap[align] || border;
|
||||
|
||||
borderCells += cell(border, node.childNodes[i]);
|
||||
if (childNode) {
|
||||
borderCells += cell(border, node.childNodes[i]);
|
||||
} else {
|
||||
borderCells += cell(border, null, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '\n' + content + (borderCells ? '\n' + borderCells : '')
|
||||
@@ -73,13 +81,27 @@ rules.table = {
|
||||
// Only convert tables with a heading row.
|
||||
// Tables with no heading row are kept using `keep` (see below).
|
||||
filter: function (node) {
|
||||
return node.nodeName === 'TABLE' && isHeadingRow(node.rows[0])
|
||||
return node.nodeName === 'TABLE'
|
||||
},
|
||||
|
||||
replacement: function (content) {
|
||||
replacement: function (content, node) {
|
||||
if (tableShouldBeSkipped(node)) return content;
|
||||
|
||||
// Ensure there are no blank lines
|
||||
content = content.replace('\n\n', '\n');
|
||||
return '\n\n' + content + '\n\n'
|
||||
content = content.replace(/\n+/g, '\n');
|
||||
|
||||
// If table has no heading, add an empty one so as to get a valid Markdown table
|
||||
var secondLine = content.trim().split('\n');
|
||||
if (secondLine.length >= 2) secondLine = secondLine[1];
|
||||
var secondLineIsDivider = secondLine.indexOf('| ---') === 0;
|
||||
|
||||
var columnCount = tableColCount(node);
|
||||
var emptyHeader = '';
|
||||
if (columnCount && !secondLineIsDivider) {
|
||||
emptyHeader = '|' + ' |'.repeat(columnCount) + '\n' + '|' + ' --- |'.repeat(columnCount);
|
||||
}
|
||||
|
||||
return '\n\n' + emptyHeader + content + '\n\n'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,16 +142,71 @@ function isFirstTbody (element) {
|
||||
)
|
||||
}
|
||||
|
||||
function cell (content, node) {
|
||||
var index = indexOf.call(node.parentNode.childNodes, node);
|
||||
function cell (content, node = null, index = null) {
|
||||
if (index === null) index = indexOf.call(node.parentNode.childNodes, node);
|
||||
var prefix = ' ';
|
||||
if (index === 0) prefix = '| ';
|
||||
return prefix + content + ' |'
|
||||
let filteredContent = content.trim().replace(/\n\r/g, '<br>').replace(/\n/g, "<br>");
|
||||
filteredContent = filteredContent.replace(/\|+/g, '\\|');
|
||||
while (filteredContent.length < 3) filteredContent += ' ';
|
||||
if (node) filteredContent = handleColSpan(filteredContent, node, ' ');
|
||||
return prefix + filteredContent + ' |'
|
||||
}
|
||||
|
||||
function nodeContainsTable(node) {
|
||||
if (!node.childNodes) return false;
|
||||
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const child = node.childNodes[i];
|
||||
if (child.nodeName === 'TABLE') return true;
|
||||
if (nodeContainsTable(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Various conditions under which a table should be skipped - i.e. each cell
|
||||
// will be rendered one after the other as if they were paragraphs.
|
||||
function tableShouldBeSkipped(tableNode) {
|
||||
if (!tableNode) return true;
|
||||
if (!tableNode.rows) return true;
|
||||
if (tableNode.rows.length === 1 && tableNode.rows[0].childNodes.length <= 1) return true; // Table with only one cell
|
||||
|
||||
// Not sure why we're excluding this. possibly because it'll freak out the parser? --evilchili
|
||||
//if (nodeContainsTable(tableNode)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function nodeParentTable(node) {
|
||||
let parent = node.parentNode;
|
||||
while (parent.nodeName !== 'TABLE') {
|
||||
parent = parent.parentNode;
|
||||
if (!parent) return null;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
function handleColSpan(content, node, emptyChar) {
|
||||
const colspan = node.getAttribute('colspan') || 1;
|
||||
for (let i = 1; i < colspan; i++) {
|
||||
content += ' | ' + emptyChar.repeat(3);
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
function tableColCount(node) {
|
||||
let maxColCount = 0;
|
||||
for (let i = 0; i < node.rows.length; i++) {
|
||||
const row = node.rows[i];
|
||||
const colCount = row.childNodes.length;
|
||||
if (colCount > maxColCount) maxColCount = colCount;
|
||||
}
|
||||
return maxColCount
|
||||
}
|
||||
|
||||
function tables (turndownService) {
|
||||
turndownService.keep(function (node) {
|
||||
return node.nodeName === 'TABLE' && !isHeadingRow(node.rows[0])
|
||||
return node.nodeName === 'TABLE'
|
||||
});
|
||||
for (var key in rules) turndownService.addRule(key, rules[key]);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -113,6 +113,7 @@ table {
|
||||
|
||||
th {
|
||||
border-bottom: 1px solid #000;
|
||||
padding: 3px;
|
||||
}
|
||||
th, td {
|
||||
padding: 2px;
|
||||
@@ -206,3 +207,148 @@ footer {
|
||||
menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: block;
|
||||
float: left;
|
||||
margin:5px;
|
||||
max-width: 30%;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: block;
|
||||
float: right;
|
||||
margin: 5px;
|
||||
max-width: 30%;
|
||||
}
|
||||
|
||||
.center {
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.box {
|
||||
border-radius: 5px;
|
||||
border: 1px solid black;
|
||||
background: #DEDEDE;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.striped > table {
|
||||
background: #FAFAFA;
|
||||
}
|
||||
.striped > table tr:nth-child(even) {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.equal-widths > table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout > table {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.layout > table > thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table td table {
|
||||
max-width:95%;
|
||||
}
|
||||
|
||||
pre {
|
||||
border: 1px dashed black;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin: 5px;
|
||||
background: #EEE;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
border: 1px dashed black;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
background: #EEE;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
div.macro {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
div[data-macro-name="toc"] {
|
||||
display: inline;
|
||||
float: left;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
margin-right: 2em;
|
||||
margin-bottom: 2em;
|
||||
border: 1px solid #000;
|
||||
padding: 0ch 2ch;
|
||||
}
|
||||
|
||||
div[data-macro-name="toc"] ul {
|
||||
box-sizing: border-box;
|
||||
list-style: none;
|
||||
padding-left: 2ch;
|
||||
font-weight: normal;
|
||||
margin-left: 0px;
|
||||
|
||||
}
|
||||
|
||||
div[data-macro-name="toc"] > ul:first-child {
|
||||
padding-left: 0ch;
|
||||
margin-left: 0px;
|
||||
}
|
||||
div[data-macro-name="toc"] > ul:first-child > li {
|
||||
padding-left: 0ch;
|
||||
margin-left: 0px;
|
||||
}
|
||||
div[data-macro-name="toc"] > ul:first-child > li {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div[data-macro-name="toc"] > ul > li {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div[data-macro-name="toc"] > ul > li {
|
||||
padding-left: 2ch;
|
||||
}
|
||||
|
||||
div[data-macro-name="toc"] a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
div[data-macro-name="toc"] a:hover {
|
||||
background: #CCC;
|
||||
}
|
||||
|
||||
|
||||
#content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#content.loaded {
|
||||
display: block;
|
||||
}
|
||||
#content.wysiwyg {
|
||||
display: none,
|
||||
}
|
||||
#content.view {
|
||||
}
|
||||
|
||||
#content.edit {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#content.wysiwyg {
|
||||
}
|
||||
|
||||
#content.wysiwyg .md {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
APIv1 = {
|
||||
get: function(doc_id, callback) {
|
||||
(async () => {
|
||||
const raw = await fetch('/_/v1/get/' + doc_id, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const res = await raw.json();
|
||||
if (res['code'] != 200) {
|
||||
console.error("APIv1 error: ", res)
|
||||
}
|
||||
callback(res);
|
||||
})();
|
||||
},
|
||||
|
||||
put: function(data, callback) {
|
||||
(async () => {
|
||||
const raw = await fetch('/_/v1/put/' + window.location.pathname, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'body': data
|
||||
}),
|
||||
});
|
||||
const res = await raw.json();
|
||||
if (res['code'] != 200) {
|
||||
console.error("APIv1 error: ", res)
|
||||
}
|
||||
callback(res);
|
||||
})();
|
||||
},
|
||||
|
||||
search: function(space, query, callback) {
|
||||
(async () => {
|
||||
const raw = await fetch('/_/v1/search/' + space, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'body': query
|
||||
}),
|
||||
});
|
||||
const res = await raw.json();
|
||||
if (res['code'] != 200) {
|
||||
console.error("APIv1 error: ", res)
|
||||
}
|
||||
callback(res);
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
const WIDGETS = {};
|
||||
|
||||
function parseWidgetSource(html) {
|
||||
|
||||
function block(prefix) {
|
||||
return RegExp('##\\s*' + prefix + '.*?```\\w*(.+?)```', 'gims');
|
||||
};
|
||||
|
||||
const template = block("Template").exec(html)[1];
|
||||
const css = block("CSS").exec(html)[1];
|
||||
const processor = block("Processor").exec(html)[1];
|
||||
|
||||
var func;
|
||||
eval("func = " + processor);
|
||||
|
||||
return {
|
||||
template: template,
|
||||
css: css,
|
||||
processor: func
|
||||
};
|
||||
}
|
||||
|
||||
async function processWidgets(html, callback) {
|
||||
|
||||
var widgetPattern = /({{(.+)}})/gm;
|
||||
|
||||
if (!html.match(widgetPattern)) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
html.matchAll(widgetPattern).forEach(match => {
|
||||
var widgetTag = match[1];
|
||||
var widgetName = match[2];
|
||||
if (Object.values(WIDGETS).indexOf(widgetName) == -1) {
|
||||
APIv1.search("Widget", widgetName, (res) => {
|
||||
if (res.code == 200) {
|
||||
var parts = parseWidgetSource(res.response[0].body);
|
||||
WIDGETS[widgetName] = parts.processor;
|
||||
contents = WIDGETS[widgetName](widgetTag, parts.template, parts.css);
|
||||
} else {
|
||||
contents = `Invalid widget: ${widgetName}`;
|
||||
}
|
||||
var rep = `<span class="widget-${widgetName}" data-source="${widgetTag}">${contents}</span>`;
|
||||
html = html.replaceAll(widgetTag, rep);
|
||||
if (parts) {
|
||||
html = `<style type='text/css'>${parts.css}</style>${html}`;
|
||||
}
|
||||
callback(html);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,9 +0,0 @@
|
||||
@import 'toastui-editor-viewer.min.css';
|
||||
|
||||
#viewer {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.toastui-editor-contents {
|
||||
font-size: var(--default-font-size);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
var viewer = new toastui.Editor({
|
||||
viewer: true,
|
||||
el: document.querySelector("#viewer"),
|
||||
usageStatistics: false,
|
||||
});
|
||||
viewer.setMarkdown(document.getElementById("data_form__body").value);
|
||||
Reference in New Issue
Block a user