From aefb9c84d2cdc29a89d4cd2478ce28f8c0daf4b0 Mon Sep 17 00:00:00 2001 From: David Calhoun <438664+dcalhoun@users.noreply.github.com> Date: Wed, 1 Mar 2023 10:00:21 -0500 Subject: [PATCH] fix: Enable access to block settings within UBE (#48435) * fix: Enable access to block settings within UBE The "Show more settings" menu item is no longer included in the block toolbar after #46709 merged. Rather than relying upon that menu item, this conditionally displays the sidebar toggle button whenever a block is selected. * fix: Disable white space stripping that breaks nested CSS selectors CSS selectors rely upon a single white space between selectors to represent an ancestor relationship. Globally removing white space in the stylesheet breaks this functionality, as it transforms the selector to target a single element with all the selectors. The white space stripping should likely be replaced with a proper CSS minification long term. * fix: Hide block actions unrelated to editing a single block Hide the entire "block settings" drop-down menu now that we no longer rely upon it to access the "Show more settings" menu option that was removed entirely. * refactor: Relocate script toggling block settings visibility This relates more to editor behavior than the post content. * fix: Apply styles and script to editor canvas iframe The editor canvas now relies upon an iframe. It is not possible to style elements within an iframe from the parent context. This copies the styles from the parent conext to the iframe. Additionally, the logic selecting the first block also failed due to the block not existing when it was invoked. This relocates that logic until after the iframe is ready. * fix: Expand conditional checks for partial DOM trees On Android, there were times where the iframe was present, but the nested window was not yet ready. * refactor: Rename for brevity * fix: Avoid React removing appended iframe styles Append the styles to the `document` element, as React will remove the mutation to the `head` element. * docs: Add change log entry --- .../GutenbergWebViewActivity.java | 10 +- .../content-functions.js | 7 - .../editor-behavior-overrides.js | 216 +++++++++++++----- .../editor-style-overrides.css | 59 ++--- .../gutenberg-web-single-block/inject-css.js | 3 +- .../FallbackJavascriptInjection.swift | 4 +- ...utenbergWebSingleBlockViewController.swift | 1 + .../react-native-bridge/ios/SourceFile.swift | 5 + packages/react-native-editor/CHANGELOG.md | 3 + 9 files changed, 201 insertions(+), 107 deletions(-) diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergWebViewActivity.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergWebViewActivity.java index 2b3302b75e2575..c4ae7e350f4cb7 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergWebViewActivity.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergWebViewActivity.java @@ -40,7 +40,7 @@ public class GutenbergWebViewActivity extends AppCompatActivity { public static final String ARG_BLOCK_CONTENT = "block_content"; private static final String INJECT_LOCAL_STORAGE_SCRIPT_TEMPLATE = "localStorage.setItem('WP_DATA_USER_%d','%s')"; - private static final String INJECT_CSS_SCRIPT_TEMPLATE = "window.injectCss('%s')"; + private static final String INJECT_CSS_SCRIPT_TEMPLATE = "window.injectCss('%s', '%s')"; private static final String INJECT_GET_HTML_POST_CONTENT_SCRIPT = "window.getHTMLPostContent();"; private static final String INJECT_ON_SHOW_CONTEXT_MENU_SCRIPT = "window.onShowContextMenu();"; private static final String INJECT_ON_HIDE_CONTEXT_MENU_SCRIPT = "window.onHideContextMenu();"; @@ -327,16 +327,16 @@ private void injectCssScript() { mWebView.evaluateJavascript(injectCssScript, message -> { if (message != null) { String editorStyle = getFileContentFromAssets("gutenberg-web-single-block/editor-style-overrides.css"); - editorStyle = removeWhiteSpace(removeNewLines(editorStyle)); - evaluateJavaScript(String.format(INJECT_CSS_SCRIPT_TEMPLATE, editorStyle)); + editorStyle = removeNewLines(editorStyle); + evaluateJavaScript(String.format(INJECT_CSS_SCRIPT_TEMPLATE, editorStyle, "editor-style-overrides")); String injectWPBarsCssScript = getFileContentFromAssets("gutenberg-web-single-block/wp-bar-override.css"); injectWPBarsCssScript = removeWhiteSpace(removeNewLines(injectWPBarsCssScript)); - evaluateJavaScript(String.format(INJECT_CSS_SCRIPT_TEMPLATE, injectWPBarsCssScript)); + evaluateJavaScript(String.format(INJECT_CSS_SCRIPT_TEMPLATE, injectWPBarsCssScript, "wp-bar-override")); String injectExternalCssScript = getOnGutenbergReadyExternalStyles(); injectExternalCssScript = removeWhiteSpace(removeNewLines(injectExternalCssScript)); - evaluateJavaScript(String.format(INJECT_CSS_SCRIPT_TEMPLATE, injectExternalCssScript)); + evaluateJavaScript(String.format(INJECT_CSS_SCRIPT_TEMPLATE, injectExternalCssScript, "external-styles")); } }); } diff --git a/packages/react-native-bridge/common/gutenberg-web-single-block/content-functions.js b/packages/react-native-bridge/common/gutenberg-web-single-block/content-functions.js index d6e7d51375d441..611701c237b095 100644 --- a/packages/react-native-bridge/common/gutenberg-web-single-block/content-functions.js +++ b/packages/react-native-bridge/common/gutenberg-web-single-block/content-functions.js @@ -25,19 +25,12 @@ window.getHTMLPostContent = () => { }; window.insertBlock = ( blockHTML ) => { - const { blockEditorSelect, blockEditorDispatch } = - window.getBlockEditorStore(); - // Setup the editor with the inserted block. const post = window.wp.data.select( 'core/editor' ).getCurrentPost(); window.wp.data .dispatch( 'core/editor' ) .setupEditor( post, { content: blockHTML } ); - // Select the first block. - const clientId = blockEditorSelect.getBlocks()[ 0 ].clientId; - blockEditorDispatch.selectBlock( clientId ); - window.contentIncerted = true; }; diff --git a/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js b/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js index 0cfa0e9985fa02..09dcd6447824d7 100644 --- a/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js +++ b/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js @@ -1,59 +1,167 @@ -// Listeners for native context menu visibility changes. -let isContextMenuVisible = false; -const hideContextMenuListeners = []; - -window.onShowContextMenu = () => { - isContextMenuVisible = true; -}; -window.onHideContextMenu = () => { - isContextMenuVisible = false; - while ( hideContextMenuListeners.length > 0 ) { - const listener = hideContextMenuListeners.pop(); - listener(); - } -}; - -/* -This is a fix for a text selection quirk in the UBE. -It notifies the Android app to dismiss the text selection -context menu when certain menu items are tapped. This is -done via the 'hideTextSelectionContextMenu' method, which -is sent back to the Android app, where the dismissal is -then handle. See PR for further details: -https://github.com/WordPress/gutenberg/pull/34668 -*/ -window.addEventListener( - 'click', - ( event ) => { - const selected = document.getSelection(); - if ( ! isContextMenuVisible || ! selected || ! selected.toString() ) { - return; +/** + * Detects whether the user agent is Android. + * + * @return {boolean} Whether the user agent is Android. + */ +function isAndroid() { + return !! window.navigator.userAgent.match( /Android/ ); +} + +/** + * This is a fix for a text selection quirk in the UBE. It notifies the Android + * app to dismiss the text selection context menu when certain menu items are + * tapped. This is done via the 'hideTextSelectionContextMenu' method, which + * is sent back to the Android app, where the dismissal is then handle. + * + * @return {void} + * @see https://github.com/WordPress/gutenberg/pull/34668 + */ +function manageTextSelectonContextMenu() { + // Listeners for native context menu visibility changes. + let isContextMenuVisible = false; + const hideContextMenuListeners = []; + + window.onShowContextMenu = () => { + isContextMenuVisible = true; + }; + window.onHideContextMenu = () => { + isContextMenuVisible = false; + while ( hideContextMenuListeners.length > 0 ) { + const listener = hideContextMenuListeners.pop(); + listener(); } + }; - // Check if the event is triggered by a dropdown - // toggle button. - const dropdownToggles = document.querySelectorAll( - '.components-dropdown-menu > button' - ); - let currentToggle; - for ( const node of dropdownToggles.values() ) { - if ( node.contains( event.target ) ) { - currentToggle = node; - break; + window.addEventListener( + 'click', + ( event ) => { + const selected = document.getSelection(); + if ( + ! isContextMenuVisible || + ! selected || + ! selected.toString() + ) { + return; } - } - // Hide text selection context menu when the click - // is triggered by a dropdown toggle. - // - // NOTE: The event propagation is prevented because - // it will be dispatched after the context menu - // is hidden. - if ( currentToggle ) { - event.stopPropagation(); - hideContextMenuListeners.push( () => currentToggle.click() ); - window.wpwebkit.hideTextSelectionContextMenu(); + // Check if the event is triggered by a dropdown + // toggle button. + const dropdownToggles = document.querySelectorAll( + '.components-dropdown-menu > button' + ); + let currentToggle; + for ( const node of dropdownToggles.values() ) { + if ( node.contains( event.target ) ) { + currentToggle = node; + break; + } + } + + // Hide text selection context menu when the click + // is triggered by a dropdown toggle. + // + // NOTE: The event propagation is prevented because + // it will be dispatched after the context menu + // is hidden. + if ( currentToggle ) { + event.stopPropagation(); + hideContextMenuListeners.push( () => currentToggle.click() ); + window.wpwebkit.hideTextSelectionContextMenu(); + } + }, + true + ); +} + +if ( isAndroid() ) { + manageTextSelectonContextMenu(); +} + +const editor = document.querySelector( '#editor' ); + +function _toggleBlockSelectedClass( isBlockSelected ) { + if ( isBlockSelected ) { + editor.classList.add( 'is-block-selected' ); + } else { + editor.classList.remove( 'is-block-selected' ); + } +} + +/** @typedef {import('@wordpress/data').WPDataRegistry} WPDataRegistry */ + +/** + * Toggle the `is-block-selected` class on the editor container when a block is + * selected. This is used to hide the sidebar toggle button when a block is not + * selected. + * + * @param {WPDataRegistry} registry Data registry. + * @return {WPDataRegistry} Modified data registry. + */ +function toggleBlockSelectedStyles( registry ) { + return { + dispatch: ( namespace ) => { + const namespaceName = + typeof namespace === 'string' ? namespace : namespace.name; + const actions = { ...registry.dispatch( namespaceName ) }; + + const originalSelectBlockAction = actions.selectBlock; + actions.selectBlock = ( ...args ) => { + _toggleBlockSelectedClass( true ); + return originalSelectBlockAction( ...args ); + }; + + const originalClearSelectedBlockAction = actions.clearSelectedBlock; + actions.clearSelectedBlock = ( ...args ) => { + _toggleBlockSelectedClass( false ); + return originalClearSelectedBlockAction( ...args ); + }; + + return actions; + }, + }; +} + +window.wp.data.use( toggleBlockSelectedStyles ); + +// The editor-canvas iframe relies upon `srcdoc`, which does not trigger a +// `load` event. Thus, we must poll for the iframe to be ready. +let overrideAttempts = 0; +const overrideInterval = setInterval( () => { + overrideAttempts++; + const overrideStyles = document.querySelector( '#editor-style-overrides' ); + const canvasIframe = document.querySelector( + 'iframe[name="editor-canvas"]' + ); + + if ( + overrideStyles && + canvasIframe && + canvasIframe.contentDocument && + canvasIframe.contentDocument.documentElement + ) { + clearInterval( overrideInterval ); + + // Clone the editor styles so that they can be copied to the iframe, as + // elements within an iframe cannot be styled from the parent context. + const overrideStylesClone = overrideStyles.cloneNode( true ); + overrideStylesClone.id = 'editor-styles-overrides-2'; + // Append to document rather than the head, as React will remove this + // mutation. + canvasIframe.contentDocument.documentElement.appendChild( + overrideStylesClone + ); + + // Select the first block. + const { blockEditorSelect, blockEditorDispatch } = + window.getBlockEditorStore(); + const firstBlock = blockEditorSelect.getBlocks()[ 0 ]; + if ( firstBlock ) { + blockEditorDispatch.selectBlock( firstBlock.clientId ); } - }, - true -); + } + + // Safeguard against an infinite loop. + if ( overrideAttempts > 100 ) { + clearInterval( overrideInterval ); + } +}, 300 ); diff --git a/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css b/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css index 7aa208abe55373..f8f2e8fe2b4cde 100644 --- a/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css +++ b/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css @@ -17,14 +17,22 @@ display: none; } -/* - Hiddes the top bar header by setting its height to 0 - We can\'t remove it since the block toolbar is a child of it. - */ +/* Right align post header children as we will only display one child */ .edit-post-header { - height: 0px; - padding: 0px; - overflow: hidden; + justify-content: flex-end; +} + +/* Hide post controls unrelated to editing a single block */ +.edit-post-header__toolbar, +.edit-post-layout .edit-post-header .edit-post-header__settings > *, +.interface-pinned-items > * { + display: none; +} + +/* Display the sidebar toggle button whenever a block is selected */ +.edit-post-layout .edit-post-header .edit-post-header__settings .interface-pinned-items, +.is-block-selected .edit-post-header__settings .interface-pinned-items > button:first-child { + display: flex; } /* Move the block toolbar to the top */ @@ -36,6 +44,11 @@ top: 0px; } +/* Hide block actions unrelated to editing a single block */ +.block-editor-block-settings-menu { + display: none; +} + /* Moves the whole editor to the top. There was an extra top margin after removing the WP Admin bar. @@ -69,38 +82,6 @@ display: none; } -/* - Load second button in component menu group but hide it from view. - This is to fix a Chrome-specific bug that occurs if this button is set to "display: none;" - For additional context, see: https://github.com/WordPress/gutenberg/pull/33740 -*/ -.components-dropdown-menu__menu - > .components-menu-group - > div - > button:nth-child( 2 ) { - display: block; - min-height: 0; - height: 0; - padding: 0; -} - -.components-menu-group > div > button:nth-child( 2 ) > span { - display: none; -} - -.components-button:focus:not( :disabled ) { - box-shadow: none; -} - -/* Remove \'delete block\' button inside \'...\' button in block toolbar */ -.components-dropdown-menu__menu > div:not(:first-child) { - display: none; -} - -.components-dropdown-menu__menu > div:first-child { - padding-bottom: 0; -} - /* Some Themes can overwrite values on \'editor-styles-wrapper\'. This will ensure that the top padding is correct on our single-block version of gutenberg web. diff --git a/packages/react-native-bridge/common/gutenberg-web-single-block/inject-css.js b/packages/react-native-bridge/common/gutenberg-web-single-block/inject-css.js index 60ac677bd20f5c..483e742e780bcb 100644 --- a/packages/react-native-bridge/common/gutenberg-web-single-block/inject-css.js +++ b/packages/react-native-bridge/common/gutenberg-web-single-block/inject-css.js @@ -1,8 +1,9 @@ const injectCss = ` -window.injectCss = (css) => { +window.injectCss = (css, id) => { const style = document.createElement('style'); style.innerHTML = css; style.type = 'text/css'; + style.id = id; document.head.appendChild(style); } `; diff --git a/packages/react-native-bridge/ios/GutenbergWebFallback/FallbackJavascriptInjection.swift b/packages/react-native-bridge/ios/GutenbergWebFallback/FallbackJavascriptInjection.swift index 13ae9dd1f05735..ee75dc0968a586 100644 --- a/packages/react-native-bridge/ios/GutenbergWebFallback/FallbackJavascriptInjection.swift +++ b/packages/react-native-bridge/ios/GutenbergWebFallback/FallbackJavascriptInjection.swift @@ -19,6 +19,7 @@ public struct FallbackJavascriptInjection { public let preventAutosavesScript: WKUserScript public let getHtmlContentScript = "window.getHTMLPostContent()".toJsScript() public let gutenbergObserverScript: WKUserScript + public let editorBehaviorScript: WKUserScript /// Init an instance of GutenbergWebJavascriptInjection or throws if any of the required sources doesn't exist. /// This helps to cach early any possible error due to missing source files. @@ -31,7 +32,7 @@ public struct FallbackJavascriptInjection { } func getInjectCssScript(with source: SourceFile) throws -> WKUserScript { - "window.injectCss(`\(try source.getContent())`)".toJsScript() + "window.injectCss(`\(try source.getContent())`, `\(source.getName())`)".toJsScript() } userContentScripts = [ @@ -44,6 +45,7 @@ public struct FallbackJavascriptInjection { injectEditorCssScript = try getInjectCssScript(with: .editorStyle) preventAutosavesScript = try script(with: .preventAutosaves) gutenbergObserverScript = try script(with: .gutenbergObserver) + editorBehaviorScript = try script(with: .editorBehavior) let localStorageJsonString = try SourceFile.localStorage.getContent().removingSpacesAndNewLines() let scriptString = String(format: injectLocalStorageScriptTemplate, userId, localStorageJsonString) diff --git a/packages/react-native-bridge/ios/GutenbergWebFallback/GutenbergWebSingleBlockViewController.swift b/packages/react-native-bridge/ios/GutenbergWebFallback/GutenbergWebSingleBlockViewController.swift index aa83095f058f10..8d11028728a891 100644 --- a/packages/react-native-bridge/ios/GutenbergWebFallback/GutenbergWebSingleBlockViewController.swift +++ b/packages/react-native-bridge/ios/GutenbergWebFallback/GutenbergWebSingleBlockViewController.swift @@ -76,6 +76,7 @@ open class GutenbergWebSingleBlockViewController: UIViewController { onGutenbergReadyScripts().forEach(evaluateJavascript) evaluateJavascript(jsInjection.preventAutosavesScript) evaluateJavascript(jsInjection.insertBlockScript) + evaluateJavascript(jsInjection.editorBehaviorScript) DispatchQueue.main.async { [weak self] in self?.removeCoverViewAnimated() } diff --git a/packages/react-native-bridge/ios/SourceFile.swift b/packages/react-native-bridge/ios/SourceFile.swift index 2f75e22278c0f1..2386f84580464f 100644 --- a/packages/react-native-bridge/ios/SourceFile.swift +++ b/packages/react-native-bridge/ios/SourceFile.swift @@ -29,6 +29,10 @@ public struct SourceFile { } extension SourceFile { + public func getName() -> String { + return self.name + } + public func jsScript(with argument: String? = nil) throws -> WKUserScript { let content = try getContent() let formatted = String(format: content, argument ?? []) @@ -53,4 +57,5 @@ extension SourceFile { static let preventAutosaves = SourceFile(name: "prevent-autosaves", type: .js) static let gutenbergObserver = SourceFile(name: "gutenberg-observer", type: .js) static let supportedBlocks = SourceFile(name: "supported-blocks", type: .json) + static let editorBehavior = SourceFile(name: "editor-behavior-overrides", type: .js) } diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 8b8c395a67d9e9..e34f5bfd753ee2 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -12,6 +12,9 @@ For each user feature we should also add a importance categorization label to i ## Unreleased - [*] Add metadata parameter to media upload events [#48103] +## 1.89.1 +- [*] Fix inaccessible block settings within the unsupported block editor [#48435] + ## 1.89.0 * No User facing changes *