From 53b565790dd3de70647dafad5b6eec6d3e9a2d57 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Tue, 11 Aug 2026 15:06:58 +0200 Subject: [PATCH 1/2] Make tinymce text area follow dark/light mode. --- biome.json | 2 +- hypha/settings/base.py | 2 +- .../javascript/tinymce-dark-mode.js | 64 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 hypha/static_src/javascript/tinymce-dark-mode.js diff --git a/biome.json b/biome.json index 2ba03b6ebd..d97dd7c7d5 100644 --- a/biome.json +++ b/biome.json @@ -100,6 +100,6 @@ "formatter": { "trailingCommas": "es5" }, - "globals": ["define", "htmx", "Alpine"] + "globals": ["define", "htmx", "Alpine", "tinymce"] } } diff --git a/hypha/settings/base.py b/hypha/settings/base.py index 9bdf4d2960..ab41e88041 100644 --- a/hypha/settings/base.py +++ b/hypha/settings/base.py @@ -522,7 +522,7 @@ # TinyMCE settings TINYMCE_EXTRA_MEDIA = { - "js": ["js/tinymce-word-count.js"], + "js": ["js/tinymce-dark-mode.js", "js/tinymce-word-count.js"], } # COMPRESSOR setting does not work but would be good to have in place. # TINYMCE_JS_ROOT = os.path.join(STATIC_URL, "tinymce") diff --git a/hypha/static_src/javascript/tinymce-dark-mode.js b/hypha/static_src/javascript/tinymce-dark-mode.js new file mode 100644 index 0000000000..6f03e6c46d --- /dev/null +++ b/hypha/static_src/javascript/tinymce-dark-mode.js @@ -0,0 +1,64 @@ +// The TinyMCE editing area is an iframe, so the page stylesheet does not reach +// it and it stays white in dark mode. Use TinyMCE's bundled dark content +// stylesheet instead. +// +// Loaded via TINYMCE_EXTRA_MEDIA, which places it after tinymce.min.js but +// before django_tinymce/init_tinymce.js, so the default is set before any +// editor is initialised. +(function () { + /** + * Name of the bundled content stylesheet matching the current theme. + * @returns {string} "dark" or "default" + */ + function contentCss() { + const theme = document.documentElement.dataset.theme; + const isDark = + theme === "dark" || + (theme !== "light" && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + return isDark ? "dark" : "default"; + } + + tinymce.overrideDefaults({ content_css: contentCss() }); + + /** + * Re-point an already initialised editor at the other content stylesheet. + * + * Swapping the href keeps the editor alive, so the undo stack and cursor + * position survive a theme change. + * + * @param {Object} editor - TinyMCE editor instance. + * @param {string} name - "dark" or "default". + */ + function swapContentCss(editor, name) { + const doc = editor.getDoc?.(); + if (!doc) { + return; + } + + const link = Array.from( + doc.querySelectorAll('link[rel="stylesheet"]') + ).find((stylesheet) => stylesheet.href.includes("/skins/content/")); + + if (link) { + link.href = `${tinymce.baseURL}/skins/content/${name}/content${tinymce.suffix}.css`; + } + } + + // The theme toggle sets data-theme on . It also re-sets the attribute + // when the OS preference changes while in "auto" mode, so this covers both. + new MutationObserver(function () { + const name = contentCss(); + + // Editors created from here on, e.g. by HTMX swapping in a new form. + tinymce.overrideDefaults({ content_css: name }); + + for (const editor of tinymce.get() ?? []) { + swapContentCss(editor, name); + } + }).observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); +})(); From 53efced763cf1add3a7cbfee5375c05fe7fb8b57 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Tue, 11 Aug 2026 15:51:52 +0200 Subject: [PATCH 2/2] Swap css on AddEditor to catch all possibilities. --- hypha/static_src/javascript/tinymce-dark-mode.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/hypha/static_src/javascript/tinymce-dark-mode.js b/hypha/static_src/javascript/tinymce-dark-mode.js index 6f03e6c46d..2a7bf9a5a1 100644 --- a/hypha/static_src/javascript/tinymce-dark-mode.js +++ b/hypha/static_src/javascript/tinymce-dark-mode.js @@ -41,11 +41,23 @@ doc.querySelectorAll('link[rel="stylesheet"]') ).find((stylesheet) => stylesheet.href.includes("/skins/content/")); - if (link) { - link.href = `${tinymce.baseURL}/skins/content/${name}/content${tinymce.suffix}.css`; + const href = `${tinymce.baseURL}/skins/content/${name}/content${tinymce.suffix}.css`; + + if (link && link.href !== href) { + link.href = href; } } + // An editor freezes content_css when it is constructed, but its iframe + // document only exists once it is initialised. A theme change in between is + // therefore invisible to both overrideDefaults and swapContentCss, so re-sync + // every editor as it becomes ready. + tinymce.on("AddEditor", function (event) { + event.editor.on("init", function () { + swapContentCss(event.editor, contentCss()); + }); + }); + // The theme toggle sets data-theme on . It also re-sets the attribute // when the OS preference changes while in "auto" mode, so this covers both. new MutationObserver(function () {