Skip to content

Commit baf41e6

Browse files
authored
feat: Expose autocomplete triggers and text insertion utility (#165)
* feat: Observe `@` autocomplete triggers * feat: Observe `+` autocomplete triggers * feat: Notify native host on autocomplete trigger Enable the host to respond to autocomplete events. * feat: Expose `appendTextAtCursor` bridge method * fix: Avoid restarting autocompletion after selecting option Debouncing ensures the native suggestion UI does not reappear after selecting an suggestion. * feat: iOS apps receive autocompletion events * feat: iOS apps append text at cursor * 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. * 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 * fix: Only trigger autocompleter on initial trigger insertion Avoid triggering autocompleter when modifying string prefixed with trigger--e.g., deleting an existing string. Behavior: - @<cursor> triggers the event (filterValue is empty) - @query<cursor> 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. * fix: Prevent autocompletion unless white space surrounds prefix trigger The autocompletion should not disrupt typing strings like an email--e.g., `[email protected]`. * fix: Authenticate editor assets request for Android * docs: Improve documentation * refactor: Rely upon shorthand property name * 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. * 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. * refactor: Reduce log noise * fix: Expand iOS string encoding to avoid stripping content The previous implementation might lead to data loss by stripping unsupported characters.
1 parent b7f54ff commit baf41e6

File tree

14 files changed

+451
-62
lines changed

14 files changed

+451
-62
lines changed

android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class GutenbergView : WebView {
5858
private var openMediaLibraryListener: OpenMediaLibraryListener? = null
5959
private var editorDidBecomeAvailableListener: EditorAvailableListener? = null
6060
private var logJsExceptionListener: LogJsExceptionListener? = null
61+
private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null
6162

6263
var textEditorEnabled: Boolean = false
6364
set(value) {
@@ -88,6 +89,10 @@ class GutenbergView : WebView {
8889
logJsExceptionListener = listener
8990
}
9091

92+
fun setAutocompleterTriggeredListener(listener: AutocompleterTriggeredListener) {
93+
autocompleterTriggeredListener = listener
94+
}
95+
9196
fun setOnFileChooserRequestedListener(listener: (Intent, Int) -> Unit) {
9297
onFileChooserRequested = listener
9398
}
@@ -403,6 +408,10 @@ class GutenbergView : WebView {
403408
fun onLogJsException(exception: GutenbergJsException)
404409
}
405410

411+
interface AutocompleterTriggeredListener {
412+
fun onAutocompleterTriggered(type: String)
413+
}
414+
406415
fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) {
407416
if (!isEditorLoaded) {
408417
Log.e("GutenbergView", "You can't change the editor content until it has loaded")
@@ -445,6 +454,17 @@ class GutenbergView : WebView {
445454
}
446455
}
447456

457+
fun appendTextAtCursor(text: String) {
458+
if (!isEditorLoaded) {
459+
Log.e("GutenbergView", "You can't append text until the editor has loaded")
460+
return
461+
}
462+
val encodedText = encodeForEditor(text)
463+
handler.post {
464+
this.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null)
465+
}
466+
}
467+
448468
@JavascriptInterface
449469
fun onEditorLoaded() {
450470
Log.i("GutenbergView", "EditorLoaded received in native code")
@@ -547,6 +567,13 @@ class GutenbergView : WebView {
547567
Log.i("GutenbergView", "BlockPickerShouldShow")
548568
}
549569

570+
@JavascriptInterface
571+
fun onAutocompleterTriggered(type: String) {
572+
handler.post {
573+
autocompleterTriggeredListener?.onAutocompleterTriggered(type)
574+
}
575+
}
576+
550577
fun resetFilePathCallback() {
551578
filePathCallback = null
552579
}
@@ -562,6 +589,7 @@ class GutenbergView : WebView {
562589
editorDidBecomeAvailable = null
563590
filePathCallback = null
564591
onFileChooserRequested = null
592+
autocompleterTriggeredListener = null
565593
handler.removeCallbacksAndMessages(null)
566594
this.destroy()
567595
}

ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ struct EditorJSMessage {
3737
case showBlockPicker
3838
/// User requested the Media Library.
3939
case openMediaLibrary
40+
/// The user triggered an autocompleter.
41+
case onAutocompleterTriggered
4042
}
4143

4244
struct DidUpdateBlocksBody: Decodable {
@@ -51,4 +53,8 @@ struct EditorJSMessage {
5153
struct DidUpdateFeaturedImageBody: Decodable {
5254
let mediaID: Int
5355
}
56+
57+
struct AutocompleterTriggeredBody: Decodable {
58+
let type: String
59+
}
5460
}

ios/Sources/GutenbergKit/Sources/EditorViewController.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
297297
evaluate("editor.setMediaUploadAttachment(\(media));")
298298
}
299299

300+
/// Appends text at the current cursor position in the editor.
301+
///
302+
/// - parameter text: The text to append at the cursor position.
303+
public func appendTextAtCursor(_ text: String) {
304+
let escapedText = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? text
305+
evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));")
306+
}
307+
300308
// MARK: - GutenbergEditorControllerDelegate
301309

302310
fileprivate func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) {
@@ -326,6 +334,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
326334
case .openMediaLibrary:
327335
let config = try message.decode(OpenMediaLibraryAction.self)
328336
openMediaLibrary(config)
337+
case .onAutocompleterTriggered:
338+
let body = try message.decode(EditorJSMessage.AutocompleterTriggeredBody.self)
339+
delegate?.editor(self, didTriggerAutocompleter: body.type)
329340
}
330341
} catch {
331342
fatalError("failed to decode message: \(error)")

ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ public protocol EditorViewControllerDelegate: AnyObject {
3434
func editor(_ viewController: EditorViewController, didLogException error: GutenbergJSException)
3535

3636
func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction)
37+
38+
/// Notifies the client that an autocompleter was triggered.
39+
///
40+
/// - parameter type: The type of autocompleter that was triggered (e.g., "plus-symbol", "at-symbol").
41+
func editor(_ viewController: EditorViewController, didTriggerAutocompleter type: String)
3742
}
3843

3944
public struct EditorState {

src/components/editor/index.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { useMediaUpload } from './use-media-upload';
1919
import TextEditor from '../text-editor';
2020
import { useSyncFeaturedImage } from './use-sync-featured-image';
2121
import { useDevModeNotice } from './use-dev-mode-notice';
22+
import { useAtAutocompleter } from './use-at-autocompleter';
23+
import { usePlusAutocompleter } from './use-plus-autocompleter';
2224

2325
/**
2426
* @typedef {import('../utils/bridge').Post} Post
@@ -43,6 +45,8 @@ export default function Editor( { post, children, hideTitle } ) {
4345
useEditorSetup( post );
4446
useMediaUpload();
4547
useDevModeNotice();
48+
useAtAutocompleter();
49+
usePlusAutocompleter();
4650

4751
const { isReady, mode, isRichEditingEnabled, currentPost } = useSelect(
4852
( select ) => {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { addFilter, removeFilter } from '@wordpress/hooks';
5+
import { useEffect } from '@wordpress/element';
6+
7+
/**
8+
* Internal dependencies
9+
*/
10+
import { onAutocompleterTriggered } from '../../utils/bridge';
11+
12+
/**
13+
* Adds a filter for the Autocomplete completers to show an alert when @ is typed.
14+
*
15+
* @return {void}
16+
*/
17+
export function useAtAutocompleter() {
18+
useEffect( () => {
19+
// Avoid conflicts with core's default autocompleter
20+
removeFilter(
21+
'editor.Autocomplete.completers',
22+
'editor/autocompleters/set-default-completers'
23+
);
24+
25+
addFilter(
26+
'editor.Autocomplete.completers',
27+
'GutenbergKit/at-symbol-alert',
28+
addAtSymbolCompleter
29+
);
30+
31+
return () => {
32+
removeFilter(
33+
'editor.Autocomplete.completers',
34+
'GutenbergKit/at-symbol-alert'
35+
);
36+
};
37+
}, [] );
38+
}
39+
40+
/**
41+
* Adds the @ symbol autocompleter to the completers array.
42+
*
43+
* @param {Array} completers Existing completers.
44+
* @return {Array} Updated completers array.
45+
*/
46+
function addAtSymbolCompleter( completers = [] ) {
47+
const atSymbolCompleter = {
48+
name: 'at-symbol',
49+
triggerPrefix: '@',
50+
options: ( filterValue ) => {
51+
// Only trigger when cursor is directly after @ (no characters typed yet)
52+
if ( filterValue === '' ) {
53+
onAutocompleterTriggered( 'at-symbol' );
54+
}
55+
// Return empty array since we're not providing actual completion options
56+
return [];
57+
},
58+
allowContext: ( before, after ) => {
59+
const beforeEmptyOrWhitespace = /^$|\s$/.test( before );
60+
const afterEmptyOrWhitespace = /^$|^\s/.test( after );
61+
return beforeEmptyOrWhitespace && afterEmptyOrWhitespace;
62+
},
63+
isDebounced: true,
64+
};
65+
66+
return [ ...completers, atSymbolCompleter ];
67+
}

src/components/editor/use-host-bridge.js

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import { useEffect, useCallback, useRef } from '@wordpress/element';
55
import { useDispatch, useSelect } from '@wordpress/data';
66
import { store as coreStore } from '@wordpress/core-data';
77
import { store as editorStore } from '@wordpress/editor';
8-
import { parse, serialize } from '@wordpress/blocks';
8+
import { parse, serialize, getBlockType } from '@wordpress/blocks';
9+
import { store as blockEditorStore } from '@wordpress/block-editor';
10+
import { insert, create, toHTMLString } from '@wordpress/rich-text';
11+
12+
/**
13+
* Internal dependencies
14+
*/
15+
import { warn } from '../../utils/logger';
916

1017
window.editor = window.editor || {};
1118

@@ -14,6 +21,13 @@ export function useHostBridge( post, editorRef ) {
1421
const { undo, redo, switchEditorMode } = useDispatch( editorStore );
1522
const { getEditedPostAttribute, getEditedPostContent } =
1623
useSelect( editorStore );
24+
const { updateBlock, selectionChange } = useDispatch( blockEditorStore );
25+
const {
26+
getSelectedBlockClientId,
27+
getBlock,
28+
getSelectionStart,
29+
getSelectionEnd,
30+
} = useSelect( blockEditorStore );
1731

1832
const editContent = useCallback(
1933
( edits ) => {
@@ -79,6 +93,64 @@ export function useHostBridge( post, editorRef ) {
7993
switchEditorMode( mode );
8094
};
8195

96+
window.editor.appendTextAtCursor = ( text ) => {
97+
const selectedBlockClientId = getSelectedBlockClientId();
98+
99+
if ( ! selectedBlockClientId ) {
100+
warn( 'Unable to append text: no block selected' );
101+
return false;
102+
}
103+
104+
const block = getBlock( selectedBlockClientId );
105+
106+
if ( ! block ) {
107+
warn(
108+
'Unable to append text: could not retrieve selected block'
109+
);
110+
return false;
111+
}
112+
113+
const blockType = getBlockType( block.name );
114+
const hasContentAttribute = blockType?.attributes?.content;
115+
116+
if ( ! hasContentAttribute ) {
117+
warn(
118+
`Unable to append text: block type ${ block.name } does not support text content`
119+
);
120+
return false;
121+
}
122+
123+
const blockContent = block.attributes?.content || '';
124+
const currentValue = create( { html: blockContent } );
125+
const selectionStart = getSelectionStart();
126+
const selectionEnd = getSelectionEnd();
127+
const newValue = insert(
128+
currentValue,
129+
text,
130+
selectionStart?.offset,
131+
selectionEnd?.offset
132+
);
133+
134+
updateBlock( selectedBlockClientId, {
135+
attributes: {
136+
...block.attributes,
137+
content: toHTMLString( { value: newValue } ),
138+
},
139+
} );
140+
141+
const newCursorPosition =
142+
selectionStart?.offset + text.length || newValue.text.length;
143+
144+
selectionChange( {
145+
clientId: selectionStart?.clientId || selectedBlockClientId,
146+
attributeKey: selectionStart?.attributeKey || 'content',
147+
startOffset: newCursorPosition,
148+
endOffset: newCursorPosition,
149+
} );
150+
151+
return true;
152+
};
153+
82154
return () => {
83155
delete window.editor.setContent;
84156
delete window.editor.setTitle;
@@ -87,6 +159,7 @@ export function useHostBridge( post, editorRef ) {
87159
delete window.editor.undo;
88160
delete window.editor.redo;
89161
delete window.editor.switchEditorMode;
162+
delete window.editor.appendTextAtCursor;
90163
};
91164
}, [
92165
editorRef,
@@ -96,6 +169,12 @@ export function useHostBridge( post, editorRef ) {
96169
redo,
97170
switchEditorMode,
98171
undo,
172+
getSelectedBlockClientId,
173+
getBlock,
174+
getSelectionStart,
175+
getSelectionEnd,
176+
updateBlock,
177+
selectionChange,
99178
] );
100179
}
101180

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { addFilter, removeFilter } from '@wordpress/hooks';
5+
import { useEffect } from '@wordpress/element';
6+
7+
/**
8+
* Internal dependencies
9+
*/
10+
import { onAutocompleterTriggered } from '../../utils/bridge';
11+
12+
/**
13+
* Adds a filter for the Autocomplete completers to show an alert when + is typed.
14+
*
15+
* @return {void}
16+
*/
17+
export function usePlusAutocompleter() {
18+
useEffect( () => {
19+
addFilter(
20+
'editor.Autocomplete.completers',
21+
'GutenbergKit/plus-symbol-alert',
22+
addPlusSymbolCompleter
23+
);
24+
25+
return () => {
26+
removeFilter(
27+
'editor.Autocomplete.completers',
28+
'GutenbergKit/plus-symbol-alert'
29+
);
30+
};
31+
}, [] );
32+
}
33+
34+
/**
35+
* Adds the + symbol autocompleter to the completers array.
36+
*
37+
* @param {Array} completers Existing completers.
38+
* @return {Array} Updated completers array.
39+
*/
40+
function addPlusSymbolCompleter( completers = [] ) {
41+
const plusSymbolCompleter = {
42+
name: 'plus-symbol',
43+
triggerPrefix: '+',
44+
options: ( filterValue ) => {
45+
// Only trigger when cursor is directly after + (no characters typed yet)
46+
if ( filterValue === '' ) {
47+
onAutocompleterTriggered( 'plus-symbol' );
48+
}
49+
// Return empty array since we're not providing actual completion options
50+
return [];
51+
},
52+
allowContext: ( before, after ) => {
53+
const beforeEmptyOrWhitespace = /^$|\s$/.test( before );
54+
const afterEmptyOrWhitespace = /^$|^\s/.test( after );
55+
return beforeEmptyOrWhitespace && afterEmptyOrWhitespace;
56+
},
57+
isDebounced: true,
58+
};
59+
60+
return [ ...completers, plusSymbolCompleter ];
61+
}

0 commit comments

Comments
 (0)