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

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);
}

View File

@ -18,6 +18,11 @@ import {
userNotesApplyWindowSettings userNotesApplyWindowSettings
} from "./user-notes-settings.js"; } from "./user-notes-settings.js";
import {
userNotesNormalizeStoredNotesForEditor,
userNotesSanitizeHtml
} from "./user-notes-sanitize.js";
let userNotesSaveTimer = null; let userNotesSaveTimer = null;
export function userNotesSetStatus(text) { export function userNotesSetStatus(text) {
@ -35,7 +40,7 @@ export function userNotesDebouncedSave(value) {
userNotesSetStatus("Ungespeichert …"); userNotesSetStatus("Ungespeichert …");
userNotesSaveTimer = window.setTimeout(() => { userNotesSaveTimer = window.setTimeout(() => {
userNotesSaveNotes(value); userNotesSaveNotes(userNotesSanitizeHtml(value));
userNotesSetStatus("Gespeichert"); userNotesSetStatus("Gespeichert");
}, 250); }, 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() { export function userNotesOpenNotes() {
let win = document.getElementById(USER_NOTES_WINDOW_ID); let win = document.getElementById(USER_NOTES_WINDOW_ID);
@ -249,7 +392,15 @@ export function userNotesOpenNotes() {
userNotesRestorePosition(win); userNotesRestorePosition(win);
userNotesApplyWindowSettings(win); userNotesApplyWindowSettings(win);
userNotesBringToFront(win); userNotesBringToFront(win);
win.querySelector("textarea")?.focus();
const editor = win.__userNotesEditor;
if (editor) {
editor.focus();
} else {
win.querySelector(".user-notes-editor")?.focus();
}
return; return;
} }
@ -278,7 +429,7 @@ export function userNotesOpenNotes() {
</header> </header>
<main class="user-notes-content"> <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> </main>
`; `;
@ -287,27 +438,23 @@ export function userNotesOpenNotes() {
userNotesApplyWindowSettings(win); userNotesApplyWindowSettings(win);
userNotesRestorePosition(win); userNotesRestorePosition(win);
const textarea = win.querySelector(".user-notes-textarea"); const editorElement = win.querySelector(".user-notes-editor");
if (!(textarea instanceof HTMLTextAreaElement)) { if (!(editorElement instanceof HTMLTextAreaElement)) {
console.error(`${USER_NOTES_MODULE_ID} | Notes textarea could not be created.`); console.error(`${USER_NOTES_MODULE_ID} | Notes editor could not be created.`);
return; return;
} }
textarea.value = userNotesLoadNotes(); userNotesCreateEditor(win, editorElement);
textarea.addEventListener("input", event => {
userNotesDebouncedSave(event.currentTarget.value);
});
win.querySelector(".user-notes-save")?.addEventListener("click", () => { win.querySelector(".user-notes-save")?.addEventListener("click", () => {
userNotesSaveNotes(textarea.value); userNotesSaveNotes(userNotesGetEditorValue(win));
userNotesSetStatus("Gespeichert"); userNotesSetStatus("Gespeichert");
userNotesSavePosition(win); userNotesSavePosition(win);
}); });
win.querySelector(".user-notes-close")?.addEventListener("click", () => { win.querySelector(".user-notes-close")?.addEventListener("click", () => {
userNotesSaveNotes(textarea.value); userNotesSaveNotes(userNotesGetEditorValue(win));
userNotesSavePosition(win); userNotesSavePosition(win);
win.hidden = true; win.hidden = true;
}); });
@ -334,7 +481,12 @@ export function userNotesOpenNotes() {
userNotesMakeDraggable(win); userNotesMakeDraggable(win);
userNotesBringToFront(win); userNotesBringToFront(win);
textarea.focus();
if (win.__userNotesEditor) {
win.__userNotesEditor.focus();
} else {
editorElement.focus();
}
} }
export function userNotesRefreshOpenWindow() { export function userNotesRefreshOpenWindow() {
@ -344,15 +496,10 @@ export function userNotesRefreshOpenWindow() {
return; return;
} }
const textarea = oldWin.querySelector(".user-notes-textarea"); userNotesSaveNotes(userNotesGetEditorValue(oldWin));
if (textarea instanceof HTMLTextAreaElement) {
userNotesSaveNotes(textarea.value);
}
userNotesSavePosition(oldWin); userNotesSavePosition(oldWin);
userNotesDestroyEditor(oldWin);
oldWin.remove(); oldWin.remove();
userNotesOpenNotes(); userNotesOpenNotes();
} }

View File

@ -8,11 +8,13 @@ import {
import { import {
userNotesOpenNotes, userNotesOpenNotes,
userNotesRefreshOpenWindow,
userNotesResetPositionAndSize userNotesResetPositionAndSize
} from "./user-notes-window.js"; } from "./user-notes-window.js";
globalThis.UserNotes = { globalThis.UserNotes = {
open: userNotesOpenNotes, open: userNotesOpenNotes,
refresh: userNotesRefreshOpenWindow,
resetPosition: userNotesResetPositionAndSize resetPosition: userNotesResetPositionAndSize
}; };
@ -42,4 +44,4 @@ Hooks.on("getSceneControlButtons", controls => {
"User Notes: Fehler beim Registrieren des Token-Controls." "User Notes: Fehler beim Registrieren des Token-Controls."
); );
} }
}); });

View File

@ -102,7 +102,35 @@
box-sizing: border-box; box-sizing: border-box;
} }
.user-notes-textarea { .user-notes-content .jodit-container {
flex: 1 1 auto;
width: 100% !important;
height: 100% !important;
min-width: 0;
min-height: 0;
box-sizing: border-box;
}
.user-notes-content .jodit-workplace {
min-height: 0 !important;
}
.user-notes-content .jodit-wysiwyg {
min-height: 0 !important;
background: var(--user-notes-textarea-background, rgba(255, 255, 255, 0.92));
color: var(--user-notes-textarea-color, #111111);
}
.user-notes-content .jodit-toolbar__box {
flex: 0 0 auto;
}
.user-notes-content .jodit-status-bar {
display: none;
}
.user-notes-editor {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
@ -127,7 +155,18 @@
line-height: 1.35; line-height: 1.35;
} }
.user-notes-textarea:focus { .user-notes-editor:focus {
outline: 1px solid var(--color-border-highlight, #ff6400); outline: 1px solid var(--color-border-highlight, #ff6400);
outline-offset: 0; outline-offset: 0;
} }
.user-notes-window table {
border-collapse: collapse;
width: auto;
}
.user-notes-window th,
.user-notes-window td {
border: 1px solid currentColor;
padding: 0.25rem 0.4rem;
}

5568
vendor/jodit/CHANGELOG.md vendored Normal file

File diff suppressed because it is too large Load Diff

19
vendor/jodit/LICENSE.txt vendored Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2013-2026 https://xdsoft.net
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

3
vendor/jodit/VERSION.txt vendored Normal file
View File

@ -0,0 +1,3 @@
Jodit Editor 4.12.2
Source: https://github.com/xdan/jodit/tree/4.12.2
License: MIT

1
vendor/jodit/jodit.min.css vendored Normal file

File diff suppressed because one or more lines are too long

272
vendor/jodit/jodit.min.js vendored Normal file

File diff suppressed because one or more lines are too long