Skip to content
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 @@ -69,7 +69,7 @@ class EditorInteractionTest {
EditorTestHelpers.waitForEnabled(composeTestRule, "Undo")

// Insert a Paragraph block and type in the content area.
EditorTestHelpers.typeInContent("World")
EditorTestHelpers.typeInContent("World", composeTestRule)

// Undo should still be enabled after typing content.
EditorTestHelpers.waitForEnabled(composeTestRule, "Undo")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.example.gutenbergkit

import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
Expand Down Expand Up @@ -43,8 +48,6 @@ object EditorTestHelpers {
"textarea[placeholder='Add title']"
private const val CODE_EDITOR_CONTENT_SELECTOR =
"textarea[placeholder='Start writing with text or HTML']"
private const val INSERTER_DIALOG_SELECTOR =
"[role='dialog'][aria-modal='true']"

/**
* Navigates from the main list through the configuration screen
Expand Down Expand Up @@ -114,35 +117,53 @@ object EditorTestHelpers {
}

/**
* Opens the web block inserter and inserts a block by name.
* Opens the native block inserter and inserts a block by name.
*
* Taps the "Add block" toggle in the editor toolbar, then clicks
* the block option matching [name] inside the inserter popover.
* Taps the "Add block" toggle in the editor toolbar, then clicks the
* block tile matching [name] in the native block picker. The toggle
* lives in the WebView, but the picker it presents is native Compose —
* the demo app enables the native inserter by default, so tapping the
* toggle dispatches over the bridge rather than opening a web popover.
* Mirrors `EditorUITestHelpers.insertBlock(_:webView:app:)` on iOS.
*/
fun insertBlock(name: String) {
fun insertBlock(
name: String,
rule: EditorTestRule
) {
// Tap the "Add block" toggle button in the WebView toolbar.
waitForWebViewElement(ADD_BLOCK_SELECTOR, ELEMENT_TIMEOUT_MS)
onWebView()
.forceJavascriptEnabled()
.withElement(findElement(Locator.CSS_SELECTOR, ADD_BLOCK_SELECTOR))
.perform(webClick())
// Wait for the inserter dialog to appear, then find and click the block
// option by name. Block items use role="option" with their accessible
// name from inner text — we match via XPath within the modal dialog.
waitForWebViewElement(INSERTER_DIALOG_SELECTOR, ELEMENT_TIMEOUT_MS)
onWebView()
.forceJavascriptEnabled()
.withElement(findElement(Locator.XPATH, inserterOptionXpath(name)))
.perform(webClick())
// Wait for the native picker, then tap the tile by name. `BlockTile`
// is a clickable `Role.Button`, and `clickable` merges descendants, so
// the label resolves onto the tile itself in the merged tree — match
// the text there rather than on a descendant.
//
// Scope to the block grid, the only `CollectionInfo` node in the
// sheet, so the match cannot stray outside it. No current block title
// collides with a category tab — `hasText` compares exactly, so
// "Media & Text" does not match the "Text" tab — but the tabs, close
// button, and search field are all clickable and labelled, so the
// scoping is what keeps that a property of the matcher rather than a
// coincidence of the current strings.
val tile = hasClickAction() and
hasText(name) and
hasAnyAncestor(SemanticsMatcher.keyIsDefined(SemanticsProperties.CollectionInfo))
rule.waitForNode(tile, NAVIGATE_TIMEOUT_MS)
rule.onNode(tile).performClick()
}

/**
* Inserts a Paragraph block via the web block inserter then types
* Inserts a Paragraph block via the native block inserter then types
* text into the empty block placeholder.
*/
fun typeInContent(text: String) {
insertBlock("Paragraph")
fun typeInContent(
text: String,
rule: EditorTestRule
) {
insertBlock("Paragraph", rule)
// Wait for the empty block to appear after insertion.
waitForWebViewElement(EMPTY_BLOCK_SELECTOR, ELEMENT_TIMEOUT_MS)
onWebView()
Expand Down Expand Up @@ -271,13 +292,6 @@ object EditorTestHelpers {
return result.value?.toString() ?: ""
}

/**
* Returns an XPath that matches a block option by [name] inside
* the inserter dialog (role="dialog", aria-modal="true").
*/
private fun inserterOptionXpath(name: String) =
"//*[@role='dialog'][@aria-modal='true']//*[@role='option'][normalize-space()='$name']"

/**
* Types text into the currently focused element via
* `document.execCommand('insertText')`, which is what mobile browsers
Expand Down Expand Up @@ -355,13 +369,42 @@ private fun AndroidComposeTestRule<*, *>.waitUntilAsserts(
}

/**
* Waits until a Compose node with the given [text] exists.
* Waits until exactly one Compose node matches [matcher].
*
* Waiting on an `assertExists` would swallow the ambiguous case: once two
* nodes match, waiting longer cannot reduce them to one, so the poll burns
* the full timeout and reports "condition still not satisfied" — hiding the
* real cause. Polling on the match count instead lets an ambiguous matcher
* fail immediately, with a message naming it.
*/
private fun AndroidComposeTestRule<*, *>.waitForNode(
matcher: SemanticsMatcher,
timeoutMs: Long = 10_000L
) {
// Poll on the match count rather than `assertExists`, so an ambiguous
// match fails as soon as it appears instead of burning the full timeout
// on an assertion that can never pass.
waitUntil(timeoutMs) {
onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
}
val matches = onAllNodes(matcher).fetchSemanticsNodes()
check(matches.size == 1) {
"Expected exactly 1 node matching '${matcher.description}' but found " +
"${matches.size}. Narrow the matcher — an ambiguous match cannot " +
"resolve by waiting."
}
}

/**
* Waits until exactly one Compose node with the given [text] exists.
*
* Every caller clicks the node afterwards, which already requires a unique
* match, so this shares [waitForNode]'s ambiguity check.
*/
private fun AndroidComposeTestRule<*, *>.waitForNodeWithText(
text: String,
timeoutMs: Long = 10_000L
) {
waitUntilAsserts(timeoutMs) {
onNodeWithText(text).assertExists()
}
waitForNode(hasText(text), timeoutMs)
}

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import rs.wordpress.api.kotlin.WpRequestResult
import uniffi.wp_api.PostType as WpPostType

data class SitePreparationUiState(
val enableNativeInserter: Boolean = false,
val enableNativeInserter: Boolean = true,
val enableInserterMediaStrip: Boolean = false,
val enableNetworkLogging: Boolean = false,
/** All viewable post types fetched from the site, or empty while loading. */
Expand Down
1 change: 1 addition & 0 deletions src/components/editor-toolbar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const EditorToolbar = ( { className } ) => {

const addBlockButton = enableNativeBlockInserter ? (
<NativeInserter
className="gutenberg-kit-editor-toolbar__inserter"
open={ isInserterOpened }
onToggle={ setIsInserterOpened }
/>
Expand Down
13 changes: 1 addition & 12 deletions src/components/editor-toolbar/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,6 @@ $scroll-indicator-elevation: 32;
right: 6px;
}

// Style the add block button with rounded black background
.gutenberg-kit-editor-toolbar .gutenberg-kit-add-block-button {
.gutenberg-kit-editor-toolbar__inserter {
margin-inline-start: 8px;

svg {
background: #eae9ec;
border-radius: 18px;
color: wordpress.$black;
padding: 1px;
width: 32px;
height: 32px;
display: block;
}
}
27 changes: 22 additions & 5 deletions src/components/native-inserter/index.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* External dependencies
*/
import clsx from 'clsx';

/**
* WordPress dependencies
*/
Expand Down Expand Up @@ -39,6 +44,7 @@ import useBlockTypesState from '@wordpress/block-editor/build-module/components/
/**
* Internal dependencies
*/
import './style.scss';
import { debug } from '../../utils/logger';
import {
preprocessBlockTypesForNativeInserter,
Expand All @@ -58,11 +64,16 @@ import { unlock } from '../../lock-unlock';
*
* Mimics the WordPress Inserter component API with open/onToggle props.
*
* @param {Object} props Component props
* @param {boolean} props.open Whether the inserter is open
* @param {Function} props.onToggle Callback to toggle inserter open state
* @param {Object} props Component props
* @param {string} props.className Optional CSS class for styling
* @param {boolean} props.open Whether the inserter is open
* @param {Function} props.onToggle Callback to toggle inserter open state
*/
export default function NativeBlockInserterButton( { open, onToggle } ) {
export default function NativeBlockInserterButton( {
className,
open,
onToggle,
} ) {
const buttonRef = useRef( null );
const prevOpen = useRef( false );

Expand Down Expand Up @@ -418,6 +429,8 @@ export default function NativeBlockInserterButton( { open, onToggle } ) {
prevOpen.current = open;
}, [ open, prepareAndShowInserter ] );

const classes = clsx( 'gutenberg-kit-add-block-button', className );

return (
<Button
ref={ buttonRef }
Expand All @@ -431,9 +444,13 @@ export default function NativeBlockInserterButton( { open, onToggle } ) {
prepareAndShowInserter();
} }
onMouseDown={ ( e ) => {
// Keep focus and the editor selection where they are. This
// cancels the default action only; both WebViews still apply
// `:active` from the hit test on pointer down, so the button's
// press styles are unaffected — verified on device.
e.preventDefault();
} }
className="gutenberg-kit-add-block-button"
className={ classes }
/>
);
}
140 changes: 140 additions & 0 deletions src/components/native-inserter/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
@use "sass:color";
@use "@wordpress/base-styles/colors" as wordpress;

$button-background: #eae9ec;
// Material 3's pressed state layer: `on-surface-variant` composited over the
// container. The same value serves iOS, where a UIKit highlight reads as a
// low-alpha black overlay and lands in the same range.
//
// The spec's 10% resolves to #dad9dc, which under-reads at 32px — the fill is
// largely occluded by the finger at the moment it changes. 16% is the top of
// the range that still reads as a state layer over this container rather than
// as a differently colored control.
$button-background-pressed: color.mix(#49454f, $button-background, 16%);
// iOS keeps the circle, which a radius at least half the button's size
// resolves to. Material 3 gives icon buttons a rounded square that morphs to
// full-round while pressed.
$button-radius-ios: 18px;
$button-radius-android: 10px;
$button-radius-android-pressed: 50%;
// Small controls grow under the finger rather than shrinking, so the edges
// stay visible around the contact patch. Larger surfaces are the ones that
// recede on press.
//
// At 32px this grows each edge by 2.4px — 4.8 device px at 2x. The fill is
// largely occluded by the finger, so the edges are what actually carries the
// press, and 1.08 moved them only 1.28px. The painted circle stays well
// inside the toolbar's 46px tap target, so neighbors cannot collide.
$press-scale: 1.15;
// Samples SwiftUI's `.snappy` spring — duration 0.5, bounce 0.15, which is a
// damping ratio of 0.85. SwiftUI animates a state change with a single spring
// rather than differing in and out timings, so both directions share these.
//
// This is the spring's rise shape, not a faithful settling curve: the samples
// stop at peak overshoot and the final stop is pinned to 1, where the true
// response is still ~1.005 and does not settle until ~0.7s. Truncating there
// leaves a velocity discontinuity at the end. It is imperceptible because the
// overshoot is ~0.016px at this size — under a device pixel — and the shorter
// duration keeps the press responsive. What `ease-out` cannot reproduce, and
// what this is for, is the acceleration profile of the rise.
$ios-motion-duration: 0.538s;
// Shorter for the eased fallback: without the spring's slow settling tail, the
// full duration reads as sluggish.
$ios-motion-fallback-duration: 0.3s;
$ios-motion-easing: linear(
0,
0.115,
0.336,
0.552,
0.725,
0.845,
0.923,
0.968,
0.991,
1.002,
1.006,
1.006,
1
);
// Material 3 motion tokens, matching the values in the `material3` artifact
// the Android library builds against: `DurationShort3` and `EasingStandard`.
// Short 3 is the fit for a small component's state change; the standard
// easing settles flat rather than overshooting.
$android-motion-duration: 0.15s;
$android-motion-easing: cubic-bezier(0.2, 0, 0, 1);

// Static appearance shared by both platforms. Motion is declared per platform
// below, each owning its complete transition list, so that no rule depends on
// another rule narrowing `transition-property`.
.gutenberg-kit-editor-toolbar .gutenberg-kit-add-block-button svg {
background: $button-background;
color: wordpress.$black;
padding: 1px;
width: 32px;
height: 32px;
display: block;
// Scale from the center of the painted circle. A root `<svg>` in an HTML
// document establishes an ordinary CSS box, so the initial
// `transform-origin` would already center against the border box —
// `fill-box` instead resolves against the content box, which stays
// centered on the background circle if the padding ever becomes
// asymmetric.
transform-box: fill-box;
transform-origin: center;
}

// Scoped to everything but Android, rather than to `is-ios`, so the editor
// keeps this treatment when running in a browser during development.
body:not(.is-android)
.gutenberg-kit-editor-toolbar
.gutenberg-kit-add-block-button {
svg {
border-radius: $button-radius-ios;
// `linear()` needs WKWebView 17.2, and the package deploys to iOS 17.0,
// so start from a curve every supported version honors. Without this
// fallback, 17.0 and 17.1 would drop to constant-velocity easing.
transition:
background-color $ios-motion-fallback-duration ease-out,
transform $ios-motion-fallback-duration ease-out;

@supports (transition-timing-function: linear(0, 1)) {
transition:
background-color $ios-motion-duration $ios-motion-easing,
transform $ios-motion-duration $ios-motion-easing;
}
}

&:active:not(:disabled, [aria-disabled="true"]) svg {
background: $button-background-pressed;
transform: scale($press-scale);
}
}

// Material 3 shape language: the corner morphs to full-round while pressed.
body.is-android .gutenberg-kit-editor-toolbar .gutenberg-kit-add-block-button {
svg {
border-radius: $button-radius-android;
transition:
background-color 0s,
transform $android-motion-duration $android-motion-easing,
border-radius $android-motion-duration $android-motion-easing;
}

// Material's state layer appears and disappears immediately, unlike
// UIKit's fade, so the fill is set with no transition in either
// direction — the `0s` below on press, and `background-color 0s` in the
// base rule on release. The shape and scale stay animated throughout.
//
// Declared as a full shorthand rather than a bare `transition-duration`
// so the values are not matched positionally against the base rule's
// property list, where reordering would silently retarget the `0s`.
&:active:not(:disabled, [aria-disabled="true"]) svg {
background: $button-background-pressed;
transform: scale($press-scale);
border-radius: $button-radius-android-pressed;
transition:
background-color 0s,
transform $android-motion-duration $android-motion-easing,
border-radius $android-motion-duration $android-motion-easing;
}
}
Loading