From 98eda6014af39868e2723b94981c36e2fbf40473 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 16 Feb 2024 23:36:04 +0100 Subject: [PATCH] :lipstick: [#3049] Ensure that the tinymce widgets are re-initialized on theme changes tinyMCE does not handle this itself, you need to explicitly force it to destroy and re-initialize itself. Django has two mechanisms w/r to the theme: * store the selected theme in localStorage (if unset, it's assumed to be 'auto') * set the data-theme attribute on the root html node, which triggers the relevant CSS rules to change the appearance So, whenever the theme changes, the localStorage is updated and the data-attribute on the HTML node changes. We can observe the html node changes, and use that to synchronize our (derived) global state. We don't care about dark/light/auto, only about the resulting theme of a particular choice - this result is stored in the global state. Then, our pages with tinymce editors need to subscribe to the global state changes so that they can re-initialize: * react component, easy, just make sure the 'key' prop changes and react will unmount and remount the node, triggering the re-init in the process from @tinymce/react * the django-tinymce code is trickier, since there we need to get each editor instance ourselves and subscribe to the global state to ensure we reset the editors. This further customizes the overridden code from the upstream package. --- .../js/components/admin/form_design/Editor.js | 6 +++- src/openforms/js/initTinymce.js | 23 ++++++++++++-- src/openforms/js/tinymce_appearance.js | 4 +-- src/openforms/js/utils/theme.js | 30 ++++++++++++++++++- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/openforms/js/components/admin/form_design/Editor.js b/src/openforms/js/components/admin/form_design/Editor.js index 5c8c362016..498e033659 100644 --- a/src/openforms/js/components/admin/form_design/Editor.js +++ b/src/openforms/js/components/admin/form_design/Editor.js @@ -1,8 +1,11 @@ import {Editor} from '@tinymce/tinymce-react'; import React, {useContext, useRef} from 'react'; import {useIntl} from 'react-intl'; +import {useGlobalState} from 'state-pool'; import getTinyMCEAppearance from 'tinymce_appearance'; +import {currentTheme} from 'utils/theme'; + import tinyMceConfig from '../../../../conf/tinymce_config.json'; import {TinyMceContext} from './Context'; @@ -10,8 +13,9 @@ const TinyMCEEditor = ({content, onEditorChange}) => { const editorRef = useRef(null); const tinyMceUrl = useContext(TinyMceContext); const intl = useIntl(); + const [theme] = useGlobalState(currentTheme); - const appearance = getTinyMCEAppearance(); + const appearance = getTinyMCEAppearance(theme); // when appearance changes, the key changes, which re-initializes the editor. tinymce // does not have a built-in way to change the skin/content_css on the fly. const key = `${appearance.skin}/${appearance.content_css}`; diff --git a/src/openforms/js/initTinymce.js b/src/openforms/js/initTinymce.js index 3da0b5ac3a..c0cdfb0327 100644 --- a/src/openforms/js/initTinymce.js +++ b/src/openforms/js/initTinymce.js @@ -1,5 +1,7 @@ // Taken from https://github.com/jazzband/django-tinymce/blob/master/tinymce/static/django_tinymce/init_tinymce.js -// Updated to add dark theme detection (L3, L8, L38, L67) +// Updated to handle dark/light mode +import {currentTheme} from 'utils/theme'; + import getTinyMCEAppearance from './tinymce_appearance'; ('use strict'); @@ -64,7 +66,6 @@ import getTinyMCEAppearance from './tinymce_appearance'; } function initializeTinyMCE(element, formsetName) { - appearance = getTinyMCEAppearance(); Array.from(element.querySelectorAll('.tinymce')).forEach(area => initTinyMCE(area)); } @@ -75,6 +76,7 @@ import getTinyMCEAppearance from './tinymce_appearance'; return; // throw 'tinyMCE is not loaded. If you customized TINYMCE_JS_URL, double-check its content.'; } + handleThemes(); // initialize the TinyMCE editors on load initializeTinyMCE(document); @@ -91,4 +93,21 @@ import getTinyMCEAppearance from './tinymce_appearance'; }); } }); + + // taken from the tinymce react package for inspiration: + // https://github.com/tinymce/tinymce-react/blob/e2b1907eadb751f81e01d8239fdf876e77430d43/src/main/ts/components/Editor.tsx#L139 + const resetEditor = el => { + const editor = window.tinyMCE.get(el.id); + editor.remove(); + initTinyMCE(el); + }; + + const handleThemes = () => { + appearance = getTinyMCEAppearance(currentTheme.getValue()); + + currentTheme.subscribe(newTheme => { + appearance = getTinyMCEAppearance(newTheme); + document.querySelectorAll('.tinymce').forEach(resetEditor); + }); + }; } diff --git a/src/openforms/js/tinymce_appearance.js b/src/openforms/js/tinymce_appearance.js index d17f7c2f5a..60a31e99d9 100644 --- a/src/openforms/js/tinymce_appearance.js +++ b/src/openforms/js/tinymce_appearance.js @@ -1,4 +1,4 @@ -import {THEMES, detectTheme} from 'utils/theme'; +import {THEMES} from 'utils/theme'; const APPEARANCE = { [THEMES.light]: { @@ -11,7 +11,7 @@ const APPEARANCE = { }, }; -const getTinyMCEAppearance = (theme = detectTheme()) => { +const getTinyMCEAppearance = theme => { return APPEARANCE[theme]; }; diff --git a/src/openforms/js/utils/theme.js b/src/openforms/js/utils/theme.js index 2fb98ce61e..3bf4d47b51 100644 --- a/src/openforms/js/utils/theme.js +++ b/src/openforms/js/utils/theme.js @@ -1,3 +1,7 @@ +import {createGlobalstate} from 'state-pool'; + +import {onLoaded} from 'utils/dom'; + export const THEMES = { light: 'light', dark: 'dark', @@ -7,7 +11,7 @@ export const THEMES = { * Detect the active theme in JS so that the correct theme can be applied in places * that are not simply styled through CSS. */ -export const detectTheme = () => { +const detectTheme = () => { // See admin/js/theme.js const theme = window.localStorage.getItem('theme') || 'auto'; @@ -20,3 +24,27 @@ export const detectTheme = () => { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; return prefersDark ? THEMES.dark : THEMES.light; }; + +/** + * Watches for changes to the data attribute of the html element. + * + * Note that we cannot use the storage event, as that is meant as a synchronization + * mechanism across tabs. It does not work in the same tab/window. + */ +const watchForThemeChanges = () => { + const htmlNode = document.documentElement; // the html node + + const observer = new MutationObserver(() => { + const newTheme = detectTheme(); + currentTheme.updateValue(() => newTheme); + }); + + observer.observe(htmlNode, { + attributes: true, + attributeFilter: ['data-theme'], + }); +}; + +onLoaded(watchForThemeChanges); + +export const currentTheme = createGlobalstate(detectTheme());