Implement Jodit Wysiwyg editor and sanitize functions

This commit is contained in:
Florian Zumpe
2026-05-20 11:20:33 +02:00
parent 6a37be1ce8
commit b6fd4d5f4c
9 changed files with 6287 additions and 25 deletions
+211
View File
@@ -0,0 +1,211 @@
const USER_NOTES_ALLOWED_TAGS = new Set([
"A",
"B",
"BLOCKQUOTE",
"BR",
"CAPTION",
"CODE",
"COL",
"COLGROUP",
"DIV",
"EM",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"HR",
"I",
"LI",
"OL",
"P",
"PRE",
"S",
"SPAN",
"STRONG",
"SUB",
"SUP",
"TABLE",
"TBODY",
"TD",
"TFOOT",
"TH",
"THEAD",
"TR",
"U",
"UL"
]);
const USER_NOTES_ALLOWED_ATTRIBUTES = new Set([
"class",
"colspan",
"href",
"rel",
"rowspan",
"style",
"target",
"title"
]);
const USER_NOTES_ALLOWED_CSS_PROPERTIES = new Set([
"background-color",
"border",
"border-bottom",
"border-color",
"border-left",
"border-radius",
"border-right",
"border-style",
"border-top",
"border-width",
"color",
"font-family",
"font-size",
"font-style",
"font-weight",
"margin-left",
"padding",
"text-align",
"text-decoration"
]);
function userNotesSanitizeStyle(styleValue) {
const safeRules = [];
for (const rule of String(styleValue ?? "").split(";")) {
const [rawProperty, ...rawValueParts] = rule.split(":");
if (!rawProperty || rawValueParts.length === 0) {
continue;
}
const property = rawProperty.trim().toLowerCase();
const value = rawValueParts.join(":").trim();
if (!USER_NOTES_ALLOWED_CSS_PROPERTIES.has(property)) {
continue;
}
const lowered = value.toLowerCase();
if (
lowered.includes("url(") ||
lowered.includes("expression(") ||
lowered.includes("javascript:") ||
lowered.includes("data:") ||
lowered.includes("@import") ||
lowered.includes("behavior:")
) {
continue;
}
safeRules.push(`${property}: ${value}`);
}
return safeRules.join("; ");
}
function userNotesIsSafeHref(value) {
const href = String(value ?? "").trim();
if (!href) {
return false;
}
if (href.startsWith("#")) {
return true;
}
try {
const url = new URL(href, window.location.origin);
return ["http:", "https:", "mailto:"].includes(url.protocol);
} catch (_err) {
return false;
}
}
function userNotesSanitizeElement(element) {
if (!USER_NOTES_ALLOWED_TAGS.has(element.tagName)) {
const text = document.createTextNode(element.textContent ?? "");
element.replaceWith(text);
return;
}
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
const value = attribute.value;
if (name.startsWith("on")) {
element.removeAttribute(attribute.name);
continue;
}
if (!USER_NOTES_ALLOWED_ATTRIBUTES.has(name)) {
element.removeAttribute(attribute.name);
continue;
}
if (name === "href") {
if (!userNotesIsSafeHref(value)) {
element.removeAttribute(attribute.name);
} else {
element.setAttribute("rel", "noopener noreferrer");
element.setAttribute("target", "_blank");
}
continue;
}
if (name === "target" && value !== "_blank") {
element.removeAttribute(attribute.name);
continue;
}
if (name === "style") {
const sanitizedStyle = userNotesSanitizeStyle(value);
if (sanitizedStyle) {
element.setAttribute("style", sanitizedStyle);
} else {
element.removeAttribute(attribute.name);
}
}
}
}
export function userNotesSanitizeHtml(html) {
const template = document.createElement("template");
template.innerHTML = String(html ?? "");
for (const element of [...template.content.querySelectorAll("*")]) {
userNotesSanitizeElement(element);
}
return template.innerHTML.replace(/<br>/gi, "<br />");
}
export function userNotesEscapePlainTextAsHtml(text) {
const div = document.createElement("div");
div.textContent = String(text ?? "");
return div.innerHTML.replace(/\n/g, "<br />");
}
export function userNotesLooksLikeHtml(value) {
return /<\/?[a-z][\s\S]*>/i.test(String(value ?? ""));
}
export function userNotesNormalizeStoredNotesForEditor(value) {
const content = String(value ?? "");
if (!content) {
return "";
}
if (userNotesLooksLikeHtml(content)) {
return userNotesSanitizeHtml(content);
}
return userNotesEscapePlainTextAsHtml(content);
}
+169 -22
View File
@@ -18,6 +18,11 @@ import {
userNotesApplyWindowSettings
} from "./user-notes-settings.js";
import {
userNotesNormalizeStoredNotesForEditor,
userNotesSanitizeHtml
} from "./user-notes-sanitize.js";
let userNotesSaveTimer = null;
export function userNotesSetStatus(text) {
@@ -35,7 +40,7 @@ export function userNotesDebouncedSave(value) {
userNotesSetStatus("Ungespeichert …");
userNotesSaveTimer = window.setTimeout(() => {
userNotesSaveNotes(value);
userNotesSaveNotes(userNotesSanitizeHtml(value));
userNotesSetStatus("Gespeichert");
}, 250);
}
@@ -241,6 +246,144 @@ export function userNotesMakeDraggable(win) {
});
}
function userNotesGetEditorValue(win) {
const editor = win.__userNotesEditor;
if (editor) {
return userNotesSanitizeHtml(editor.value);
}
const textarea = win.querySelector(".user-notes-editor");
if (textarea instanceof HTMLTextAreaElement) {
return userNotesSanitizeHtml(textarea.value);
}
return "";
}
function userNotesDestroyEditor(win) {
const editor = win.__userNotesEditor;
if (editor && typeof editor.destruct === "function") {
editor.destruct();
}
win.__userNotesEditor = null;
}
function userNotesInsertSymbol(editor, symbol) {
editor.s.insertHTML(`${symbol} `);
}
function userNotesCreateEditor(win, editorElement) {
const initialContent = userNotesNormalizeStoredNotesForEditor(userNotesLoadNotes());
if (!globalThis.Jodit) {
console.warn("User Notes | Jodit is not available. Falling back to plain textarea.");
editorElement.value = initialContent
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]+>/g, "");
editorElement.addEventListener("input", event => {
userNotesDebouncedSave(event.currentTarget.value);
});
return null;
}
const editor = globalThis.Jodit.make(editorElement, {
height: "100%",
minHeight: 150,
toolbarAdaptive: false,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
disablePlugins: [
"about",
"add-new-line",
"ai-assistant",
"file",
"image",
"media",
"paste-storage",
"powered-by-jodit",
"preview",
"print",
"source",
"speech-recognize",
"video"
],
buttons: [
"bold",
"italic",
"underline",
"strikethrough",
"|",
"ul",
"ol",
"|",
"font",
"fontsize",
"brush",
"|",
"table",
"|",
"undo",
"redo",
"|",
{
name: "checkbox-empty",
tooltip: "Kästchen einfügen",
icon: "☐",
exec: editorInstance => userNotesInsertSymbol(editorInstance, "☐")
},
{
name: "checkbox-checked",
tooltip: "Angehaktes Kästchen einfügen",
icon: "☑",
exec: editorInstance => userNotesInsertSymbol(editorInstance, "☑")
},
{
name: "checkmark",
tooltip: "Haken einfügen",
icon: "✓",
exec: editorInstance => userNotesInsertSymbol(editorInstance, "✓")
},
{
name: "crossmark",
tooltip: "Kreuz einfügen",
icon: "✗",
exec: editorInstance => userNotesInsertSymbol(editorInstance, "✗")
},
{
name: "bordered-text",
tooltip: "Rahmen um markierten Text",
icon: "▣",
exec: editorInstance => {
const selected = editorInstance.s.html || "&nbsp;";
editorInstance.s.insertHTML(
`<span style="border: 1px solid currentColor; padding: 2px 4px;">${selected}</span>`
);
}
}
]
});
editor.value = initialContent;
editor.events.on("change", () => {
userNotesDebouncedSave(editor.value);
});
win.__userNotesEditor = editor;
return editor;
}
export function userNotesOpenNotes() {
let win = document.getElementById(USER_NOTES_WINDOW_ID);
@@ -249,7 +392,15 @@ export function userNotesOpenNotes() {
userNotesRestorePosition(win);
userNotesApplyWindowSettings(win);
userNotesBringToFront(win);
win.querySelector("textarea")?.focus();
const editor = win.__userNotesEditor;
if (editor) {
editor.focus();
} else {
win.querySelector(".user-notes-editor")?.focus();
}
return;
}
@@ -278,7 +429,7 @@ export function userNotesOpenNotes() {
</header>
<main class="user-notes-content">
<textarea class="user-notes-textarea" spellcheck="true" placeholder="Notizen für diese Welt und diesen Benutzer …"></textarea>
<textarea class="user-notes-editor" spellcheck="true"></textarea>
</main>
`;
@@ -287,27 +438,23 @@ export function userNotesOpenNotes() {
userNotesApplyWindowSettings(win);
userNotesRestorePosition(win);
const textarea = win.querySelector(".user-notes-textarea");
const editorElement = win.querySelector(".user-notes-editor");
if (!(textarea instanceof HTMLTextAreaElement)) {
console.error(`${USER_NOTES_MODULE_ID} | Notes textarea could not be created.`);
if (!(editorElement instanceof HTMLTextAreaElement)) {
console.error(`${USER_NOTES_MODULE_ID} | Notes editor could not be created.`);
return;
}
textarea.value = userNotesLoadNotes();
textarea.addEventListener("input", event => {
userNotesDebouncedSave(event.currentTarget.value);
});
userNotesCreateEditor(win, editorElement);
win.querySelector(".user-notes-save")?.addEventListener("click", () => {
userNotesSaveNotes(textarea.value);
userNotesSaveNotes(userNotesGetEditorValue(win));
userNotesSetStatus("Gespeichert");
userNotesSavePosition(win);
});
win.querySelector(".user-notes-close")?.addEventListener("click", () => {
userNotesSaveNotes(textarea.value);
userNotesSaveNotes(userNotesGetEditorValue(win));
userNotesSavePosition(win);
win.hidden = true;
});
@@ -334,7 +481,12 @@ export function userNotesOpenNotes() {
userNotesMakeDraggable(win);
userNotesBringToFront(win);
textarea.focus();
if (win.__userNotesEditor) {
win.__userNotesEditor.focus();
} else {
editorElement.focus();
}
}
export function userNotesRefreshOpenWindow() {
@@ -344,15 +496,10 @@ export function userNotesRefreshOpenWindow() {
return;
}
const textarea = oldWin.querySelector(".user-notes-textarea");
if (textarea instanceof HTMLTextAreaElement) {
userNotesSaveNotes(textarea.value);
}
userNotesSaveNotes(userNotesGetEditorValue(oldWin));
userNotesSavePosition(oldWin);
userNotesDestroyEditor(oldWin);
oldWin.remove();
userNotesOpenNotes();
}
}
+3 -1
View File
@@ -8,11 +8,13 @@ import {
import {
userNotesOpenNotes,
userNotesRefreshOpenWindow,
userNotesResetPositionAndSize
} from "./user-notes-window.js";
globalThis.UserNotes = {
open: userNotesOpenNotes,
refresh: userNotesRefreshOpenWindow,
resetPosition: userNotesResetPositionAndSize
};
@@ -42,4 +44,4 @@ Hooks.on("getSceneControlButtons", controls => {
"User Notes: Fehler beim Registrieren des Token-Controls."
);
}
});
});