Skip to content

feat: Expose autocomplete triggers and text insertion utility #165

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -88,6 +89,10 @@ class GutenbergView : WebView {
logJsExceptionListener = listener
}

fun setAutocompleterTriggeredListener(listener: AutocompleterTriggeredListener) {
autocompleterTriggeredListener = listener
}

fun setOnFileChooserRequestedListener(listener: (Intent, Int) -> Unit) {
onFileChooserRequested = listener
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand All @@ -562,6 +589,7 @@ class GutenbergView : WebView {
editorDidBecomeAvailable = null
filePathCallback = null
onFileChooserRequested = null
autocompleterTriggeredListener = null
handler.removeCallbacksAndMessages(null)
this.destroy()
}
Expand Down
6 changes: 6 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -51,4 +53,8 @@ struct EditorJSMessage {
struct DidUpdateFeaturedImageBody: Decodable {
let mediaID: Int
}

struct AutocompleterTriggeredBody: Decodable {
let type: String
}
}
11 changes: 11 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: .urlQueryAllowed) ?? text
evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));")
}

// MARK: - GutenbergEditorControllerDelegate

fileprivate func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) {
Expand Down Expand Up @@ -326,6 +334,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)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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-symbol").
func editor(_ viewController: EditorViewController, didTriggerAutocompleter type: String)
}

public struct EditorState {
Expand Down
4 changes: 4 additions & 0 deletions src/components/editor/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ 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';
import { usePlusAutocompleter } from './use-plus-autocompleter';

/**
* @typedef {import('../utils/bridge').Post} Post
Expand All @@ -43,6 +45,8 @@ export default function Editor( { post, children, hideTitle } ) {
useEditorSetup( post );
useMediaUpload();
useDevModeNotice();
useAtAutocompleter();
usePlusAutocompleter();

const { isReady, mode, isRichEditingEnabled, currentPost } = useSelect(
( select ) => {
Expand Down
67 changes: 67 additions & 0 deletions src/components/editor/use-at-autocompleter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* WordPress dependencies
*/
import { addFilter, removeFilter } from '@wordpress/hooks';
import { useEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import { onAutocompleterTriggered } from '../../utils/bridge';

/**
* 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: ( 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 [];
},
allowContext: ( before, after ) => {
const beforeEmptyOrWhitespace = /^$|\s$/.test( before );
const afterEmptyOrWhitespace = /^$|^\s/.test( after );
return beforeEmptyOrWhitespace && afterEmptyOrWhitespace;
},
isDebounced: true,
};

return [ ...completers, atSymbolCompleter ];
}
81 changes: 80 additions & 1 deletion src/components/editor/use-host-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};

Expand All @@ -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 ) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -96,6 +169,12 @@ export function useHostBridge( post, editorRef ) {
redo,
switchEditorMode,
undo,
getSelectedBlockClientId,
getBlock,
getSelectionStart,
getSelectionEnd,
updateBlock,
selectionChange,
] );
}

Expand Down
61 changes: 61 additions & 0 deletions src/components/editor/use-plus-autocompleter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* WordPress dependencies
*/
import { addFilter, removeFilter } from '@wordpress/hooks';
import { useEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import { onAutocompleterTriggered } from '../../utils/bridge';

/**
* 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: ( 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 [];
},
allowContext: ( before, after ) => {
const beforeEmptyOrWhitespace = /^$|\s$/.test( before );
const afterEmptyOrWhitespace = /^$|^\s/.test( after );
return beforeEmptyOrWhitespace && afterEmptyOrWhitespace;
},
isDebounced: true,
};

return [ ...completers, plusSymbolCompleter ];
}
Loading