From 9b6ca18bbbe1a1e166b2e58defa73b24c70d7b8a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 5 Aug 2025 12:40:45 -0400 Subject: [PATCH 01/18] feat: Observe `@` autocomplete triggers --- src/components/editor/index.jsx | 2 + src/components/editor/use-at-autocompleter.js | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/components/editor/use-at-autocompleter.js diff --git a/src/components/editor/index.jsx b/src/components/editor/index.jsx index 4108593e..db3a3f0f 100644 --- a/src/components/editor/index.jsx +++ b/src/components/editor/index.jsx @@ -19,6 +19,7 @@ import { useMediaUpload } from './use-media-upload'; import TextEditor from '../text-editor'; import { useSyncFeaturedImage } from './use-sync-featured-image'; import { useDevModeNotice } from './use-dev-mode-notice'; +import { useAtAutocompleter } from './use-at-autocompleter'; /** * @typedef {import('../utils/bridge').Post} Post @@ -43,6 +44,7 @@ export default function Editor( { post, children, hideTitle } ) { useEditorSetup( post ); useMediaUpload(); useDevModeNotice(); + useAtAutocompleter(); const { isReady, mode, isRichEditingEnabled, currentPost } = useSelect( ( select ) => { diff --git a/src/components/editor/use-at-autocompleter.js b/src/components/editor/use-at-autocompleter.js new file mode 100644 index 00000000..898b8d6e --- /dev/null +++ b/src/components/editor/use-at-autocompleter.js @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import { addFilter, removeFilter } from '@wordpress/hooks'; +import { useEffect } from '@wordpress/element'; + +/* + * Internal dependencies + */ +import { info } from '../../utils/logger'; + +/** + * Adds a filter for the Autocomplete completers to show an alert when @ is typed. + * + * @return {void} + */ +export function useAtAutocompleter() { + useEffect( () => { + // Avoid conflicts with core's default autocompleter + removeFilter( + 'editor.Autocomplete.completers', + 'editor/autocompleters/set-default-completers' + ); + + addFilter( + 'editor.Autocomplete.completers', + 'GutenbergKit/at-symbol-alert', + addAtSymbolCompleter + ); + + return () => { + removeFilter( + 'editor.Autocomplete.completers', + 'GutenbergKit/at-symbol-alert' + ); + }; + }, [] ); +} + +/** + * Adds the @ symbol autocompleter to the completers array. + * + * @param {Array} completers Existing completers. + * @return {Array} Updated completers array. + */ +function addAtSymbolCompleter( completers = [] ) { + const atSymbolCompleter = { + name: 'at-symbol', + triggerPrefix: '@', + options: () => { + info( 'You typed an @ symbol!' ); + // Return empty array since we're not providing actual completion options + return []; + }, + getOptionLabel: () => '', + getOptionCompletion: () => '@', + }; + + return [ ...completers, atSymbolCompleter ]; +} From df4ee9b7d1eb2d4131f2e32f07975aff798adfd3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 5 Aug 2025 12:43:28 -0400 Subject: [PATCH 02/18] feat: Observe `+` autocomplete triggers --- src/components/editor/index.jsx | 2 + .../editor/use-plus-autocompleter.js | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/components/editor/use-plus-autocompleter.js diff --git a/src/components/editor/index.jsx b/src/components/editor/index.jsx index db3a3f0f..6a97d841 100644 --- a/src/components/editor/index.jsx +++ b/src/components/editor/index.jsx @@ -20,6 +20,7 @@ import TextEditor from '../text-editor'; import { useSyncFeaturedImage } from './use-sync-featured-image'; import { useDevModeNotice } from './use-dev-mode-notice'; import { useAtAutocompleter } from './use-at-autocompleter'; +import { usePlusAutocompleter } from './use-plus-autocompleter'; /** * @typedef {import('../utils/bridge').Post} Post @@ -45,6 +46,7 @@ export default function Editor( { post, children, hideTitle } ) { useMediaUpload(); useDevModeNotice(); useAtAutocompleter(); + usePlusAutocompleter(); const { isReady, mode, isRichEditingEnabled, currentPost } = useSelect( ( select ) => { diff --git a/src/components/editor/use-plus-autocompleter.js b/src/components/editor/use-plus-autocompleter.js new file mode 100644 index 00000000..3b3da3ff --- /dev/null +++ b/src/components/editor/use-plus-autocompleter.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import { addFilter, removeFilter } from '@wordpress/hooks'; +import { useEffect } from '@wordpress/element'; + +/* + * Internal dependencies + */ +import { info } from '../../utils/logger'; + +/** + * Adds a filter for the Autocomplete completers to show an alert when + is typed. + * + * @return {void} + */ +export function usePlusAutocompleter() { + useEffect( () => { + addFilter( + 'editor.Autocomplete.completers', + 'GutenbergKit/plus-symbol-alert', + addPlusSymbolCompleter + ); + + return () => { + removeFilter( + 'editor.Autocomplete.completers', + 'GutenbergKit/plus-symbol-alert' + ); + }; + }, [] ); +} + +/** + * Adds the + symbol autocompleter to the completers array. + * + * @param {Array} completers Existing completers. + * @return {Array} Updated completers array. + */ +function addPlusSymbolCompleter( completers = [] ) { + const plusSymbolCompleter = { + name: 'plus-symbol', + triggerPrefix: '+', + options: () => { + info( 'You typed a + symbol!' ); + // Return empty array since we're not providing actual completion options + return []; + }, + getOptionLabel: () => '', + getOptionCompletion: () => '+', + }; + + return [ ...completers, plusSymbolCompleter ]; +} From 141b778201ef07461ef02527661864d3734496fd Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 5 Aug 2025 13:45:05 -0400 Subject: [PATCH 03/18] feat: Notify native host on autocomplete trigger Enable the host to respond to autocomplete events. --- src/components/editor/use-at-autocompleter.js | 6 +++--- src/components/editor/use-plus-autocompleter.js | 6 +++--- src/utils/bridge.js | 11 +++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/editor/use-at-autocompleter.js b/src/components/editor/use-at-autocompleter.js index 898b8d6e..98a79b11 100644 --- a/src/components/editor/use-at-autocompleter.js +++ b/src/components/editor/use-at-autocompleter.js @@ -4,10 +4,10 @@ import { addFilter, removeFilter } from '@wordpress/hooks'; import { useEffect } from '@wordpress/element'; -/* +/** * Internal dependencies */ -import { info } from '../../utils/logger'; +import { onAutocompleterTriggered } from '../../utils/bridge'; /** * Adds a filter for the Autocomplete completers to show an alert when @ is typed. @@ -48,7 +48,7 @@ function addAtSymbolCompleter( completers = [] ) { name: 'at-symbol', triggerPrefix: '@', options: () => { - info( 'You typed an @ symbol!' ); + onAutocompleterTriggered( 'at-symbol' ); // Return empty array since we're not providing actual completion options return []; }, diff --git a/src/components/editor/use-plus-autocompleter.js b/src/components/editor/use-plus-autocompleter.js index 3b3da3ff..cfd5cea2 100644 --- a/src/components/editor/use-plus-autocompleter.js +++ b/src/components/editor/use-plus-autocompleter.js @@ -4,10 +4,10 @@ import { addFilter, removeFilter } from '@wordpress/hooks'; import { useEffect } from '@wordpress/element'; -/* +/** * Internal dependencies */ -import { info } from '../../utils/logger'; +import { onAutocompleterTriggered } from '../../utils/bridge'; /** * Adds a filter for the Autocomplete completers to show an alert when + is typed. @@ -42,7 +42,7 @@ function addPlusSymbolCompleter( completers = [] ) { name: 'plus-symbol', triggerPrefix: '+', options: () => { - info( 'You typed a + symbol!' ); + onAutocompleterTriggered( 'plus-symbol' ); // Return empty array since we're not providing actual completion options return []; }, diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 22862d1f..49b79ad1 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -121,6 +121,17 @@ export function openMediaLibrary( config ) { } } +/** + * Notifies the native host that an autocompleter was triggered. + * + * @param {string} type The type of autocompleter that was triggered (e.g. 'at-symbol', 'plus-symbol'). + * + * @return {void} + */ +export function onAutocompleterTriggered( type ) { + dispatchToBridge( 'onAutocompleterTriggered', { type } ); +} + /** * @typedef GBKitConfig * From 99f77d874e485147df0bbb1cdc7d3cd4cba33458 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 5 Aug 2025 20:58:27 -0400 Subject: [PATCH 04/18] feat: Expose `appendTextAtCursor` bridge method --- src/components/editor/use-host-bridge.js | 81 +++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index ed292861..72ebd220 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -5,7 +5,14 @@ import { useEffect, useCallback, useRef } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; -import { parse, serialize } from '@wordpress/blocks'; +import { parse, serialize, getBlockType } from '@wordpress/blocks'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { insert, create, toHTMLString } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { warn } from '../../utils/logger'; window.editor = window.editor || {}; @@ -14,6 +21,13 @@ export function useHostBridge( post, editorRef ) { const { undo, redo, switchEditorMode } = useDispatch( editorStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); + const { updateBlock, selectionChange } = useDispatch( blockEditorStore ); + const { + getSelectedBlockClientId, + getBlock, + getSelectionStart, + getSelectionEnd, + } = useSelect( blockEditorStore ); const editContent = useCallback( ( edits ) => { @@ -79,6 +93,64 @@ export function useHostBridge( post, editorRef ) { switchEditorMode( mode ); }; + window.editor.appendTextAtCursor = ( text ) => { + const selectedBlockClientId = getSelectedBlockClientId(); + + if ( ! selectedBlockClientId ) { + warn( 'Unable to append text: no block selected' ); + return false; + } + + const block = getBlock( selectedBlockClientId ); + + if ( ! block ) { + warn( + 'Unable to append text: could not retrieve selected block' + ); + return false; + } + + const blockType = getBlockType( block.name ); + const hasContentAttribute = blockType?.attributes?.content; + + if ( ! hasContentAttribute ) { + warn( + `Unable to append text: block type ${ block.name } does not support text content` + ); + return false; + } + + const blockContent = block.attributes?.content || ''; + const currentValue = create( { html: blockContent } ); + const selectionStart = getSelectionStart(); + const selectionEnd = getSelectionEnd(); + const newValue = insert( + currentValue, + text, + selectionStart?.offset, + selectionEnd?.offset + ); + + updateBlock( selectedBlockClientId, { + attributes: { + ...block.attributes, + content: toHTMLString( { value: newValue } ), + }, + } ); + + const newCursorPosition = + selectionStart?.offset + text.length || newValue.text.length; + + selectionChange( { + clientId: selectionStart?.clientId || selectedBlockClientId, + attributeKey: selectionStart?.attributeKey || 'content', + startOffset: newCursorPosition, + endOffset: newCursorPosition, + } ); + + return true; + }; + return () => { delete window.editor.setContent; delete window.editor.setTitle; @@ -87,6 +159,7 @@ export function useHostBridge( post, editorRef ) { delete window.editor.undo; delete window.editor.redo; delete window.editor.switchEditorMode; + delete window.editor.appendTextAtCursor; }; }, [ editorRef, @@ -96,6 +169,12 @@ export function useHostBridge( post, editorRef ) { redo, switchEditorMode, undo, + getSelectedBlockClientId, + getBlock, + getSelectionStart, + getSelectionEnd, + updateBlock, + selectionChange, ] ); } From c0b8d7700eb9a42337acffd329e8947aa4f5137d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 6 Aug 2025 11:13:26 -0400 Subject: [PATCH 05/18] fix: Avoid restarting autocompletion after selecting option Debouncing ensures the native suggestion UI does not reappear after selecting an suggestion. --- src/components/editor/use-at-autocompleter.js | 3 +-- src/components/editor/use-plus-autocompleter.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/editor/use-at-autocompleter.js b/src/components/editor/use-at-autocompleter.js index 98a79b11..270e93c3 100644 --- a/src/components/editor/use-at-autocompleter.js +++ b/src/components/editor/use-at-autocompleter.js @@ -52,8 +52,7 @@ function addAtSymbolCompleter( completers = [] ) { // Return empty array since we're not providing actual completion options return []; }, - getOptionLabel: () => '', - getOptionCompletion: () => '@', + isDebounced: true, }; return [ ...completers, atSymbolCompleter ]; diff --git a/src/components/editor/use-plus-autocompleter.js b/src/components/editor/use-plus-autocompleter.js index cfd5cea2..1df37a77 100644 --- a/src/components/editor/use-plus-autocompleter.js +++ b/src/components/editor/use-plus-autocompleter.js @@ -46,8 +46,7 @@ function addPlusSymbolCompleter( completers = [] ) { // Return empty array since we're not providing actual completion options return []; }, - getOptionLabel: () => '', - getOptionCompletion: () => '+', + isDebounced: true, }; return [ ...completers, plusSymbolCompleter ]; From daaeb299b00d840870aec4eba303186bd9aae955 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 6 Aug 2025 11:18:34 -0400 Subject: [PATCH 06/18] feat: iOS apps receive autocompletion events --- ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift | 6 ++++++ ios/Sources/GutenbergKit/Sources/EditorViewController.swift | 3 +++ .../GutenbergKit/Sources/EditorViewControllerDelegate.swift | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index 9f111647..6fc7de3f 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -37,6 +37,8 @@ struct EditorJSMessage { case showBlockPicker /// User requested the Media Library. case openMediaLibrary + /// The user triggered an autocompleter. + case onAutocompleterTriggered } struct DidUpdateBlocksBody: Decodable { @@ -51,4 +53,8 @@ struct EditorJSMessage { struct DidUpdateFeaturedImageBody: Decodable { let mediaID: Int } + + struct AutocompleterTriggeredBody: Decodable { + let type: String + } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index dcfea9a8..57b4ed71 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -326,6 +326,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro case .openMediaLibrary: let config = try message.decode(OpenMediaLibraryAction.self) openMediaLibrary(config) + case .onAutocompleterTriggered: + let body = try message.decode(EditorJSMessage.AutocompleterTriggeredBody.self) + delegate?.editor(self, didTriggerAutocompleter: body.type) } } catch { fatalError("failed to decode message: \(error)") diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index d8a87c84..4206fa38 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -34,6 +34,11 @@ public protocol EditorViewControllerDelegate: AnyObject { func editor(_ viewController: EditorViewController, didLogException error: GutenbergJSException) func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) + + /// Notifies the client that an autocompleter was triggered. + /// + /// - parameter type: The type of autocompleter that was triggered (e.g., "plus-symbol", "at-mention"). + func editor(_ viewController: EditorViewController, didTriggerAutocompleter type: String) } public struct EditorState { From 2c3e6bd6403bb8ca915aa67523547b9d9b3b85ff Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 6 Aug 2025 11:18:57 -0400 Subject: [PATCH 07/18] feat: iOS apps append text at cursor --- .../GutenbergKit/Sources/EditorViewController.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 57b4ed71..13c9e3df 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -297,6 +297,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.setMediaUploadAttachment(\(media));") } + /// Appends text at the current cursor position in the editor. + /// + /// - parameter text: The text to append at the cursor position. + public func appendTextAtCursor(_ text: String) { + let escapedText = text.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? text + evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));") + } + // MARK: - GutenbergEditorControllerDelegate fileprivate func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) { From a0e854e2cd74a6b75c747eef9825c8f638a552ee Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 6 Aug 2025 13:54:09 -0400 Subject: [PATCH 08/18] fix: Ensure hook registration occurs in global `wp.hooks` namespace Bundling the `@wordpress/hook` package meant two namespaces existed: 1. The module bundled by Vite; 2. And the `wp.hooks` global provided by the remote site scripts. Hooks registered by GutenbergKit used the former, but the editor runtime relied upon the latter. Therefore, the GutenbergKit hooks were never applied. Using a single namespace resolves this issue. Originally, bundling select modules was done in service of using `@wordpress/api-fetch` for fetching editor assets from a remote site. Now that the native bridge authenticates and performs this request, we no longer need these bundled modules. We can rely upon the globals provided by the remote site assets. --- src/remote.jsx | 17 ++++++----- src/utils/bridge.js | 16 +++------- src/utils/remote-editor.js | 10 +------ vite.config.remote.js | 60 +++++++++++++++++++++----------------- 4 files changed, 48 insertions(+), 55 deletions(-) diff --git a/src/remote.jsx b/src/remote.jsx index 93244991..ce169e46 100644 --- a/src/remote.jsx +++ b/src/remote.jsx @@ -1,7 +1,6 @@ /** * WordPress dependencies */ -import apiFetch from '@wordpress/api-fetch'; // Default styles that are needed for the editor. import '@wordpress/components/build-style/style.css'; import '@wordpress/block-editor/build-style/style.css'; @@ -17,16 +16,12 @@ import '@wordpress/editor/build-style/style.css'; * Internal dependencies */ import { awaitGBKitGlobal } from './utils/bridge'; -import { initializeApiFetch } from './utils/api-fetch'; import { loadEditorAssets } from './utils/remote-editor'; import { initializeVideoPressAjaxBridge } from './utils/videopress-bridge'; import { error, warn } from './utils/logger'; import { isDevMode } from './utils/dev-mode'; import './index.scss'; -window.wp = window.wp || {}; -window.wp.apiFetch = apiFetch; - const I18N_PACKAGES = [ 'i18n', 'hooks' ]; // Rely upon promises rather than async/await to avoid timeouts caused by @@ -43,12 +38,11 @@ awaitGBKitGlobal() .then( importL10n ) .then( configureLocale ) .then( loadRemainingAssets ) + .then( initializeApiFetch ) .then( initializeEditor ) .catch( handleError ); function initializeApiAndLoadI18n() { - initializeApiFetch(); - // Ensure the i18n packages are loaded, then set the locale before importing // the rest of the packages. return loadEditorAssets( { allowedPackages: I18N_PACKAGES } ); @@ -71,6 +65,15 @@ function loadRemainingAssets() { } ); } +function initializeApiFetch( assetsResult ) { + return import( './utils/api-fetch' ).then( + ( { initializeApiFetch: _initializeApiFetch } ) => { + _initializeApiFetch(); + return assetsResult; + } + ); +} + function initializeEditor( assetsResult ) { initializeVideoPressAjaxBridge(); diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 49b79ad1..20fc7500 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -1,8 +1,3 @@ -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; - /** * Internal dependencies */ @@ -298,16 +293,13 @@ export function awaitGBKitGlobal( timeoutMs = 3000 ) { export async function fetchEditorAssets() { if ( window.webkit ) { return await window.webkit.messageHandlers.loadFetchedEditorAssets.postMessage( - { - asset: 'manifest', - } + { asset: 'manifest' } ); } - // Android implementation - uses same API call that will be intercepted const { siteApiRoot, editorAssetsEndpoint } = getGBKit(); const url = editorAssetsEndpoint || `${ siteApiRoot }wpcom/v2/editor-assets`; - return await apiFetch( { - url, - } ); + // The native Android bridge intercepts this request, managing any required + // authentication configuration + return await fetch( url ); } diff --git a/src/utils/remote-editor.js b/src/utils/remote-editor.js index d70d0bab..7af092b1 100644 --- a/src/utils/remote-editor.js +++ b/src/utils/remote-editor.js @@ -107,16 +107,8 @@ async function loadAssets( html, { allowedPackages = [], disallowedPackages = [] } = {} ) { - /** - * Locally-sourced Gutenberg packages excluded from remote loading to avoid - * conflicts. - */ - const localGutenbergPackages = [ 'api-fetch', ...disallowedPackages ]; - const excludedScriptIDs = new RegExp( - localGutenbergPackages - .map( ( script ) => `wp-${ script }-js` ) - .join( '|' ) + disallowedPackages.map( ( script ) => `wp-${ script }-js` ).join( '|' ) ); const allowedScriptIDs = allowedPackages.length diff --git a/vite.config.remote.js b/vite.config.remote.js index 7c2a98df..1ae80f64 100644 --- a/vite.config.remote.js +++ b/vite.config.remote.js @@ -34,14 +34,13 @@ export default defineConfig( { } ); function externalize( id ) { - const externalDefinition = defaultRequestToExternal( id ); - return ( - !! externalDefinition && - ! id.match( /\.css(?:\?inline)?$/ ) && - ! [ 'apiFetch', 'i18n', 'url', 'hooks' ].includes( - externalDefinition[ externalDefinition.length - 1 ] - ) + const hasExternal = defaultRequestToExternal( id ) !== undefined; + const isCss = id.match( /\.css(?:\?inline)?$/ ); + const moduleWithSideEffects = [ '@wordpress/format-library' ].includes( + id ); + + return hasExternal && ! isCss && ! moduleWithSideEffects; } /** @@ -52,24 +51,22 @@ function externalize( id ) { function wordPressExternals() { return { name: 'wordpress-externals-plugin', - transform( code, id ) { + transform( code ) { const magicString = new MagicString( code ); let hasReplacements = false; // Match WordPress and React JSX runtime import statements const regex = - /import\s*(?:{([^}]+)}\s*from)?\s*['"](@wordpress\/([^'"]+)|react\/jsx-runtime)['"];/g; + /import\s*(?:(\w+)|{([^}]+)})\s*from\s*['"](@wordpress\/(?!.*\.css)[^'"]+|react\/jsx-runtime)['"];/g; let match; while ( ( match = regex.exec( code ) ) !== null ) { - const [ fullMatch, imports, module ] = match; + const [ fullMatch, defaultImport, namedImports, module ] = + match; + const imports = defaultImport || namedImports; const externalDefinition = defaultRequestToExternal( module ); - if ( - ! externalDefinition || - /@wordpress\/(api-fetch|url|hooks)/.test( id ) || - /@wordpress\/(api-fetch|url|hooks)/.test( module ) - ) { + if ( ! externalDefinition ) { continue; // Exclude the module from externalization } @@ -84,22 +81,31 @@ function wordPressExternals() { continue; } - const importList = imports.split( ',' ).map( ( i ) => { - const parts = i.trim().split( /\s+as\s+/ ); - if ( parts.length === 2 ) { - // Convert import "as" syntax to destructuring assignment - return `${ parts[ 0 ] }: ${ parts[ 1 ] }`; - } - return i.trim(); - } ); - const definitionArray = Array.isArray( externalDefinition ) ? externalDefinition : [ externalDefinition ]; - const replacement = `const { ${ importList.join( - ', ' - ) } } = window.${ definitionArray.join( '.' ) };`; + let replacement; + if ( defaultImport ) { + // Handle default import + replacement = `const ${ defaultImport } = window.${ definitionArray.join( + '.' + ) };`; + } else { + // Handle named imports + const importList = imports.split( ',' ).map( ( i ) => { + const parts = i.trim().split( /\s+as\s+/ ); + if ( parts.length === 2 ) { + // Convert import "as" syntax to destructuring assignment + return `${ parts[ 0 ] }: ${ parts[ 1 ] }`; + } + return i.trim(); + } ); + + replacement = `const { ${ importList.join( + ', ' + ) } } = window.${ definitionArray.join( '.' ) };`; + } magicString.overwrite( match.index, match.index + fullMatch.length, From 3201e112f1c23ba63c50519e4930c8cf85d49597 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 6 Aug 2025 16:39:04 -0400 Subject: [PATCH 09/18] feat: Android support for autocompleter events and text insertion Enable Android apps to: 1. Receive notifications when autocompleters are triggered (@ and + symbols) 2. Programmatically insert text at the cursor position in the editor --- .../org/wordpress/gutenberg/GutenbergView.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 56b2f70a..4b4e166a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -58,6 +58,7 @@ class GutenbergView : WebView { private var openMediaLibraryListener: OpenMediaLibraryListener? = null private var editorDidBecomeAvailableListener: EditorAvailableListener? = null private var logJsExceptionListener: LogJsExceptionListener? = null + private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null var textEditorEnabled: Boolean = false set(value) { @@ -88,6 +89,10 @@ class GutenbergView : WebView { logJsExceptionListener = listener } + fun setAutocompleterTriggeredListener(listener: AutocompleterTriggeredListener) { + autocompleterTriggeredListener = listener + } + fun setOnFileChooserRequestedListener(listener: (Intent, Int) -> Unit) { onFileChooserRequested = listener } @@ -403,6 +408,10 @@ class GutenbergView : WebView { fun onLogJsException(exception: GutenbergJsException) } + interface AutocompleterTriggeredListener { + fun onAutocompleterTriggered(type: String) + } + fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) { if (!isEditorLoaded) { Log.e("GutenbergView", "You can't change the editor content until it has loaded") @@ -445,6 +454,17 @@ class GutenbergView : WebView { } } + fun appendTextAtCursor(text: String) { + if (!isEditorLoaded) { + Log.e("GutenbergView", "You can't append text until the editor has loaded") + return + } + val encodedText = encodeForEditor(text) + handler.post { + this.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null) + } + } + @JavascriptInterface fun onEditorLoaded() { Log.i("GutenbergView", "EditorLoaded received in native code") @@ -547,6 +567,13 @@ class GutenbergView : WebView { Log.i("GutenbergView", "BlockPickerShouldShow") } + @JavascriptInterface + fun onAutocompleterTriggered(type: String) { + handler.post { + autocompleterTriggeredListener?.onAutocompleterTriggered(type) + } + } + fun resetFilePathCallback() { filePathCallback = null } @@ -562,6 +589,7 @@ class GutenbergView : WebView { editorDidBecomeAvailable = null filePathCallback = null onFileChooserRequested = null + autocompleterTriggeredListener = null handler.removeCallbacksAndMessages(null) this.destroy() } From 9d995c056484376037272eb1b672c3f8bf5f6d66 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 6 Aug 2025 21:19:57 -0400 Subject: [PATCH 10/18] fix: Only trigger autocompleter on initial trigger insertion Avoid triggering autocompleter when modifying string prefixed with trigger--e.g., deleting an existing string. Behavior: - @ triggers the event (filterValue is empty) - @query does not trigger (filterValue contains "query") - Same pattern applies to + symbol This ensures native apps only receive the initial trigger notification, not continuous events while the user is typing. --- src/components/editor/use-at-autocompleter.js | 7 +++++-- src/components/editor/use-plus-autocompleter.js | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/editor/use-at-autocompleter.js b/src/components/editor/use-at-autocompleter.js index 270e93c3..d9582302 100644 --- a/src/components/editor/use-at-autocompleter.js +++ b/src/components/editor/use-at-autocompleter.js @@ -47,8 +47,11 @@ function addAtSymbolCompleter( completers = [] ) { const atSymbolCompleter = { name: 'at-symbol', triggerPrefix: '@', - options: () => { - onAutocompleterTriggered( 'at-symbol' ); + options: ( filterValue ) => { + // Only trigger when cursor is directly after @ (no characters typed yet) + if ( filterValue === '' ) { + onAutocompleterTriggered( 'at-symbol' ); + } // Return empty array since we're not providing actual completion options return []; }, diff --git a/src/components/editor/use-plus-autocompleter.js b/src/components/editor/use-plus-autocompleter.js index 1df37a77..1ff58007 100644 --- a/src/components/editor/use-plus-autocompleter.js +++ b/src/components/editor/use-plus-autocompleter.js @@ -41,8 +41,11 @@ function addPlusSymbolCompleter( completers = [] ) { const plusSymbolCompleter = { name: 'plus-symbol', triggerPrefix: '+', - options: () => { - onAutocompleterTriggered( 'plus-symbol' ); + options: ( filterValue ) => { + // Only trigger when cursor is directly after + (no characters typed yet) + if ( filterValue === '' ) { + onAutocompleterTriggered( 'plus-symbol' ); + } // Return empty array since we're not providing actual completion options return []; }, From f94ca59ae0052d8fa0966537a503410b93b099db Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 7 Aug 2025 11:04:14 -0400 Subject: [PATCH 11/18] fix: Prevent autocompletion unless white space surrounds prefix trigger The autocompletion should not disrupt typing strings like an email--e.g., `hello@example.com`. --- src/components/editor/use-at-autocompleter.js | 5 +++++ src/components/editor/use-plus-autocompleter.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/components/editor/use-at-autocompleter.js b/src/components/editor/use-at-autocompleter.js index d9582302..0df5402f 100644 --- a/src/components/editor/use-at-autocompleter.js +++ b/src/components/editor/use-at-autocompleter.js @@ -55,6 +55,11 @@ function addAtSymbolCompleter( completers = [] ) { // Return empty array since we're not providing actual completion options return []; }, + allowContext: ( before, after ) => { + const beforeEmptyOrWhitespace = /^$|\s$/.test( before ); + const afterEmptyOrWhitespace = /^$|^\s/.test( after ); + return beforeEmptyOrWhitespace && afterEmptyOrWhitespace; + }, isDebounced: true, }; diff --git a/src/components/editor/use-plus-autocompleter.js b/src/components/editor/use-plus-autocompleter.js index 1ff58007..f8aa0ebd 100644 --- a/src/components/editor/use-plus-autocompleter.js +++ b/src/components/editor/use-plus-autocompleter.js @@ -49,6 +49,11 @@ function addPlusSymbolCompleter( completers = [] ) { // Return empty array since we're not providing actual completion options return []; }, + allowContext: ( before, after ) => { + const beforeEmptyOrWhitespace = /^$|\s$/.test( before ); + const afterEmptyOrWhitespace = /^$|^\s/.test( after ); + return beforeEmptyOrWhitespace && afterEmptyOrWhitespace; + }, isDebounced: true, }; From 81dcf29c83b674e78e78cc64d960336407efe662 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 7 Aug 2025 17:05:35 -0400 Subject: [PATCH 12/18] fix: Authenticate editor assets request for Android --- src/utils/bridge.js | 11 +++-- src/utils/fetch.js | 114 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/utils/fetch.js diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 20fc7500..e2d1b570 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -4,6 +4,7 @@ import parseException from './exception-parser'; import { debug } from './logger'; import { isDevMode } from './dev-mode'; +import { basicFetch } from './fetch'; /** * Generic function to dispatch messages to both Android and iOS bridges. @@ -296,10 +297,12 @@ export async function fetchEditorAssets() { { asset: 'manifest' } ); } - const { siteApiRoot, editorAssetsEndpoint } = getGBKit(); + + const { siteApiRoot, editorAssetsEndpoint, authHeader } = getGBKit(); const url = editorAssetsEndpoint || `${ siteApiRoot }wpcom/v2/editor-assets`; - // The native Android bridge intercepts this request, managing any required - // authentication configuration - return await fetch( url ); + // Use our fetch utility, as we have not yet loaded the `wp.apiFetch` utility + return await basicFetch( url, { + headers: { Authorization: authHeader }, + } ); } diff --git a/src/utils/fetch.js b/src/utils/fetch.js new file mode 100644 index 00000000..a5f22c4e --- /dev/null +++ b/src/utils/fetch.js @@ -0,0 +1,114 @@ +/** + * Basic fetch implementation based on `@wordpress/api-fetch`. + * + * @param {string} url The URL to fetch. + * @param {Object} options Fetch options. + * @return {Promise} Fetch promise. + */ +export function basicFetch( url, options = {} ) { + const responsePromise = window.fetch( url, options ); + + return responsePromise.then( + ( value ) => + Promise.resolve( value ) + .then( checkStatus ) + .catch( ( response ) => parseAndThrowError( response ) ) + .then( ( response ) => + parseResponseAndNormalizeError( response ) + ), + ( err ) => { + // Re-throw AbortError for the users to handle it themselves. + if ( err && err.name === 'AbortError' ) { + throw err; + } + + // Otherwise, there is most likely no network connection. + // Unfortunately the message might depend on the browser. + throw { + code: 'fetch_error', + message: 'You are probably offline.', + }; + } + ); +} + +/** + * Checks the status of a response, throwing an error if the status is not in the 200 range. + * + * @param {Response} response + * @return {Response} The response if the status is in the 200 range. + */ +function checkStatus( response ) { + if ( response.status >= 200 && response.status < 300 ) { + return response; + } + + throw response; +} + +/** + * Parses a response, throwing an error if parsing the response fails. + * + * @param {Response} response + * @return {Promise} Parsed response. + */ +function parseAndThrowError( response ) { + return parseJsonAndNormalizeError( response ).then( ( error ) => { + const unknownError = { + code: 'unknown_error', + message: 'An unknown error occurred.', + }; + + throw error || unknownError; + } ); +} + +/** + * Calls the `json` function on the Response, throwing an error if the response + * doesn't have a json function or if parsing the json itself fails. + * + * @param {Response} response + * @return {Promise} Parsed response. + */ +const parseJsonAndNormalizeError = ( response ) => { + const invalidJsonError = { + code: 'invalid_json', + message: 'The response is not a valid JSON response.', + }; + + if ( ! response || ! response.json ) { + throw invalidJsonError; + } + + return response.json().catch( () => { + throw invalidJsonError; + } ); +}; + +/** + * Parses the fetch response properly and normalize response errors. + * + * @param {Response} response + * + * @return {Promise} Parsed response. + */ +function parseResponseAndNormalizeError( response ) { + return Promise.resolve( parseResponse( response ) ).catch( ( res ) => + parseAndThrowError( res ) + ); +} + +/** + * Parses the fetch response. + * + * @param {Response} response + * + * @return {Promise | null | Response} Parsed response. + */ +function parseResponse( response ) { + if ( response.status === 204 ) { + return null; + } + + return response.json ? response.json() : Promise.reject( response ); +} From 186872801bcb32d136723e53dcfd38e11e8d918f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 8 Aug 2025 07:01:35 -0400 Subject: [PATCH 13/18] docs: Improve documentation --- .../GutenbergKit/Sources/EditorViewControllerDelegate.swift | 2 +- vite.config.remote.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 4206fa38..89b30704 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -37,7 +37,7 @@ public protocol EditorViewControllerDelegate: AnyObject { /// Notifies the client that an autocompleter was triggered. /// - /// - parameter type: The type of autocompleter that was triggered (e.g., "plus-symbol", "at-mention"). + /// - parameter type: The type of autocompleter that was triggered (e.g., "plus-symbol", "at-symbol"). func editor(_ viewController: EditorViewController, didTriggerAutocompleter type: String) } diff --git a/vite.config.remote.js b/vite.config.remote.js index 1ae80f64..79fd0bb6 100644 --- a/vite.config.remote.js +++ b/vite.config.remote.js @@ -45,6 +45,11 @@ function externalize( id ) { /** * Transform code by replacing WordPress imports with global definitions. + * E.g., `import { __ } from '@wordpress/i18n';` becomes `const { __ } = window.wp.i18n;` + * This replicates Gutenberg's behavior in a browser environment, which relies upon + * the `@wordpress/dependency-extraction-webpack-plugin` module. + * + * See: https://github.com/WordPress/gutenberg/tree/d2fce222ebbbef8dbc56eee1badcfe4ae0df04b0/packages/dependency-extraction-webpack-plugin * * @return {Object} The transformed code and map. */ From 63ba2d4eaf5c467d6232af14f0ffed63b69427f0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 8 Aug 2025 07:01:53 -0400 Subject: [PATCH 14/18] refactor: Rely upon shorthand property name --- vite.config.remote.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vite.config.remote.js b/vite.config.remote.js index 79fd0bb6..58c12632 100644 --- a/vite.config.remote.js +++ b/vite.config.remote.js @@ -18,7 +18,7 @@ export default defineConfig( { outDir: '../dist', rollupOptions: { input: resolve( __dirname, 'src/remote.html' ), - external: externalize, + external, }, target: 'esnext', }, @@ -33,7 +33,7 @@ export default defineConfig( { }, } ); -function externalize( id ) { +function external( id ) { const hasExternal = defaultRequestToExternal( id ) !== undefined; const isCss = id.match( /\.css(?:\?inline)?$/ ); const moduleWithSideEffects = [ '@wordpress/format-library' ].includes( From 07241a7d5e42109da845518447410667f8ee6f47 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 8 Aug 2025 07:30:11 -0400 Subject: [PATCH 15/18] build: Simplify side-effect module externalization We do not need special handling of side-effect modules. The regex change ensures they are matched by the Vite plugin. The logic then removes the side-effect module import entirely. This is appropriate, as the remote editor assets should already include/load the side-effect modules. E.g., the `format-library` side effects are included in remote editor assets. --- vite.config.remote.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/vite.config.remote.js b/vite.config.remote.js index 58c12632..aab37ac6 100644 --- a/vite.config.remote.js +++ b/vite.config.remote.js @@ -36,11 +36,8 @@ export default defineConfig( { function external( id ) { const hasExternal = defaultRequestToExternal( id ) !== undefined; const isCss = id.match( /\.css(?:\?inline)?$/ ); - const moduleWithSideEffects = [ '@wordpress/format-library' ].includes( - id - ); - return hasExternal && ! isCss && ! moduleWithSideEffects; + return hasExternal && ! isCss; } /** @@ -62,7 +59,7 @@ function wordPressExternals() { // Match WordPress and React JSX runtime import statements const regex = - /import\s*(?:(\w+)|{([^}]+)})\s*from\s*['"](@wordpress\/(?!.*\.css)[^'"]+|react\/jsx-runtime)['"];/g; + /import\s*(?:(?:(\w+)|{([^}]+)})\s*from\s*)?['"](@wordpress\/(?!.*\.css)[^'"]+|react\/jsx-runtime)['"];/g; let match; while ( ( match = regex.exec( code ) ) !== null ) { From 3b742b50e5db9531f201998280c525b4a9eaaf64 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 8 Aug 2025 07:52:06 -0400 Subject: [PATCH 16/18] fix: Avoid loading duplicate CSS files By ignoring CSS files within the module import regex, we loaded the CSS twice: once within the bundle, again from the remote site assets. We now match CSS imports and remove them as side-effect imports. Inlined CSS files are treated differently. We bundle these imports so that the JavaScript can act upon the imported string content. --- vite.config.remote.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/vite.config.remote.js b/vite.config.remote.js index aab37ac6..92b67524 100644 --- a/vite.config.remote.js +++ b/vite.config.remote.js @@ -35,9 +35,9 @@ export default defineConfig( { function external( id ) { const hasExternal = defaultRequestToExternal( id ) !== undefined; - const isCss = id.match( /\.css(?:\?inline)?$/ ); + const isInlineCss = id.match( /\.css\?inline$/ ); - return hasExternal && ! isCss; + return hasExternal && ! isInlineCss; } /** @@ -59,13 +59,18 @@ function wordPressExternals() { // Match WordPress and React JSX runtime import statements const regex = - /import\s*(?:(?:(\w+)|{([^}]+)})\s*from\s*)?['"](@wordpress\/(?!.*\.css)[^'"]+|react\/jsx-runtime)['"];/g; + /import\s*(?:(?:(\w+)|{([^}]+)})\s*from\s*)?['"](@wordpress\/[^'"]+|react\/jsx-runtime)['"];/g; let match; while ( ( match = regex.exec( code ) ) !== null ) { const [ fullMatch, defaultImport, namedImports, module ] = match; const imports = defaultImport || namedImports; + + if ( module.match( /\.css\?inline$/ ) ) { + continue; // Exclude inlined CSS files from externalization + } + const externalDefinition = defaultRequestToExternal( module ); if ( ! externalDefinition ) { From 64f65567b9115ef8f0acb4c5c60c5ff2d2fb2db1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 8 Aug 2025 07:58:15 -0400 Subject: [PATCH 17/18] refactor: Reduce log noise --- src/utils/videopress-bridge.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/videopress-bridge.js b/src/utils/videopress-bridge.js index 799c6524..7bed2ee2 100644 --- a/src/utils/videopress-bridge.js +++ b/src/utils/videopress-bridge.js @@ -2,7 +2,7 @@ * Internal dependencies */ import { getGBKit } from './bridge'; -import { warn, info, error } from './logger'; +import { warn, debug, error } from './logger'; /** * VideoPress AJAX to REST API bridge. @@ -62,7 +62,7 @@ export function initializeVideoPressAjaxBridge() { return deferred.promise(); }; - info( 'VideoPress AJAX bridge initialized' ); + debug( 'VideoPress AJAX bridge initialized' ); } /** @@ -89,7 +89,7 @@ function handleVideoPressUploadJWT() { }; delete processedResponse.upload_url; - info( + debug( 'VideoPress JWT obtained successfully', processedResponse ); From f5e0a7f8ba443f23562f831be31b8477fc1a3dcf Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 8 Aug 2025 08:19:07 -0400 Subject: [PATCH 18/18] fix: Expand iOS string encoding to avoid stripping content The previous implementation might lead to data loss by stripping unsupported characters. --- ios/Sources/GutenbergKit/Sources/EditorViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 13c9e3df..3a4cab08 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -301,7 +301,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// /// - parameter text: The text to append at the cursor position. public func appendTextAtCursor(_ text: String) { - let escapedText = text.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? text + let escapedText = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? text evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));") }