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..2a7bf9a5a1 --- /dev/null +++ b/hypha/static_src/javascript/tinymce-dark-mode.js @@ -0,0 +1,76 @@ +// 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/")); + + 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 () { + 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"], + }); +})();