From de0738b32e4263fbf766e5b8100636f32b528c89 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 9 Apr 2026 12:42:25 -0400 Subject: [PATCH 1/3] test: add failing test for appendTextAtCursor with RichTextData content Block attributes can be RichTextData objects (which have toString/valueOf methods) rather than plain strings or {raw, rendered} objects. normalizeAttribute incorrectly converts these to empty strings because RichTextData lacks a .raw property, causing the existing block content (e.g. a typed "@" trigger) to be lost when appendTextAtCursor rebuilds the block. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/test/use-host-bridge.test.jsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index cd6ee49b..56959145 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react'; */ import { useHostBridge } from '../use-host-bridge'; import { getBlockType } from '@wordpress/blocks'; +import { create } from '@wordpress/rich-text'; const mockGetEditedPostAttribute = vi.fn(); const mockGetEditedPostContent = vi.fn(); @@ -332,6 +333,49 @@ describe( 'useHostBridge', () => { } ); } ); + it( 'appendTextAtCursor preserves existing content when block attribute is a RichTextData object', () => { + const richTextData = { + toString: () => '@', + valueOf: () => '@', + toHTMLString: () => '@', + }; + + mockGetSelectedBlockClientId.mockReturnValue( 'block-1' ); + mockGetBlock.mockReturnValue( { + name: 'core/paragraph', + clientId: 'block-1', + attributes: { content: richTextData }, + } ); + getBlockType.mockReturnValue( { + attributes: { content: { type: 'string' } }, + } ); + mockGetSelectionStart.mockReturnValue( { + clientId: 'block-1', + attributeKey: 'content', + offset: 1, + } ); + mockGetSelectionEnd.mockReturnValue( { + clientId: 'block-1', + attributeKey: 'content', + offset: 1, + } ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + create.mockClear(); + + window.editor.appendTextAtCursor( 'username ' ); + + // create() should receive the RichTextData (or its string + // representation), NOT an empty string. + const htmlArg = create.mock.calls[ 0 ][ 0 ].html; + const htmlString = + typeof htmlArg === 'object' ? htmlArg.toString() : htmlArg; + expect( htmlString ).toBe( '@' ); + } ); + it( 'appendTextAtCursor returns false when no block is selected', () => { mockGetSelectedBlockClientId.mockReturnValue( null ); From c4cc6d700677539b600e0c2799a75a7e4ed21770 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 9 Apr 2026 12:43:12 -0400 Subject: [PATCH 2/3] fix: preserve block content when appending text at cursor appendTextAtCursor passed block content through normalizeAttribute(), which is designed for entity record attributes ({raw, rendered} objects). Block attributes from getBlock() can be RichTextData objects, which lack a .raw property. normalizeAttribute converted these to empty strings, discarding the existing block content (e.g. "Hi, my block and @") before inserting the appended text. Revert to the original fallback (`|| ''`) which lets RichTextData pass through to create(), where its toString() method correctly produces the HTML string. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/test/use-host-bridge.test.jsx | 27 +++++++++---------- src/components/editor/use-host-bridge.js | 4 +-- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 56959145..33d29a79 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -9,7 +9,6 @@ import { renderHook } from '@testing-library/react'; */ import { useHostBridge } from '../use-host-bridge'; import { getBlockType } from '@wordpress/blocks'; -import { create } from '@wordpress/rich-text'; const mockGetEditedPostAttribute = vi.fn(); const mockGetEditedPostContent = vi.fn(); @@ -334,10 +333,11 @@ describe( 'useHostBridge', () => { } ); it( 'appendTextAtCursor preserves existing content when block attribute is a RichTextData object', () => { + const existingContent = 'Existing block content @'; const richTextData = { - toString: () => '@', - valueOf: () => '@', - toHTMLString: () => '@', + toString: () => existingContent, + valueOf: () => existingContent, + toHTMLString: () => existingContent, }; mockGetSelectedBlockClientId.mockReturnValue( 'block-1' ); @@ -352,28 +352,27 @@ describe( 'useHostBridge', () => { mockGetSelectionStart.mockReturnValue( { clientId: 'block-1', attributeKey: 'content', - offset: 1, + offset: existingContent.length, } ); mockGetSelectionEnd.mockReturnValue( { clientId: 'block-1', attributeKey: 'content', - offset: 1, + offset: existingContent.length, } ); renderHook( () => useHostBridge( defaultPost, editorRef, markBridgeReady ) ); - create.mockClear(); - window.editor.appendTextAtCursor( 'username ' ); - // create() should receive the RichTextData (or its string - // representation), NOT an empty string. - const htmlArg = create.mock.calls[ 0 ][ 0 ].html; - const htmlString = - typeof htmlArg === 'object' ? htmlArg.toString() : htmlArg; - expect( htmlString ).toBe( '@' ); + // The block should contain the original content plus the + // appended text — not just the appended text alone. + expect( mockUpdateBlock ).toHaveBeenCalledWith( 'block-1', { + attributes: expect.objectContaining( { + content: 'Existing block content @username ', + } ), + } ); } ); it( 'appendTextAtCursor returns false when no block is selected', () => { diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 073aec4f..f91392f2 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -150,9 +150,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { return false; } - const blockContent = normalizeAttribute( - block.attributes?.content - ); + const blockContent = block.attributes?.content || ''; const currentValue = create( { html: blockContent } ); const selectionStart = getSelectionStart(); const selectionEnd = getSelectionEnd(); From e44fddffa326f38a3551a5bde9529b060e667a2c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 9 Apr 2026 12:56:27 -0400 Subject: [PATCH 3/3] feat(demo): add autocompleter suggestions for @ mentions and + sites Present a simple picker with hardcoded suggestions when the @ or + autocompleter triggers in the iOS and Android demo apps. This enables manual testing of the appendTextAtCursor bridge method. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/gutenbergkit/EditorActivity.kt | 17 +++++++++++++++++ ios/Demo-iOS/Sources/Views/EditorView.swift | 19 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index ab173102..fb2c2e6b 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -1,5 +1,6 @@ package com.example.gutenbergkit +import android.app.AlertDialog import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo @@ -311,6 +312,22 @@ fun EditorScreen( } } }) + setAutocompleterTriggeredListener(object : GutenbergView.AutocompleterTriggeredListener { + override fun onAutocompleterTriggered(type: String) { + val suggestions = when (type) { + "at-symbol" -> arrayOf("alice", "bob", "charlie") + "plus-symbol" -> arrayOf("photoblog", "traveldiaries", "dailydev") + else -> return + } + AlertDialog.Builder(context) + .setTitle("Select a suggestion") + .setItems(suggestions) { _, which -> + appendTextAtCursor(suggestions[which] + " ") + } + .setNegativeButton("Cancel", null) + .show() + } + }) // Demo app has no persistence layer, so return null. // In a real app, return the persisted title and content from autosave. setLatestContentProvider(object : GutenbergView.LatestContentProvider { diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 23dcf736..ca1aaed8 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -216,7 +216,24 @@ private struct _EditorView: UIViewControllerRepresentable { } func editor(_ viewController: EditorViewController, didTriggerAutocompleter type: String) { - // No-op for demo + let suggestions: [String] + switch type { + case "at-symbol": + suggestions = ["alice", "bob", "charlie"] + case "plus-symbol": + suggestions = ["photoblog", "traveldiaries", "dailydev"] + default: + return + } + + let alert = UIAlertController(title: "Select a suggestion", message: nil, preferredStyle: .actionSheet) + for suggestion in suggestions { + alert.addAction(UIAlertAction(title: suggestion, style: .default) { _ in + viewController.appendTextAtCursor(suggestion + " ") + }) + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + viewController.present(alert, animated: true) } func editor(_ viewController: EditorViewController, didOpenModalDialog dialogType: String) {