diff --git a/lib/compat/wordpress-6.9/block-comments.php b/lib/compat/wordpress-6.9/block-comments.php index 11d46ae8292d20..4b24a4edd9331b 100644 --- a/lib/compat/wordpress-6.9/block-comments.php +++ b/lib/compat/wordpress-6.9/block-comments.php @@ -1,4 +1,43 @@ 'string', + 'description' => __( 'Suggested edit payload (JSON).', 'gutenberg' ), + 'single' => true, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'string', + 'maxLength' => $max_suggestion_payload_bytes, + ), + ), + 'sanitize_callback' => function ( $value ) use ( $max_suggestion_payload_bytes ) { + if ( ! is_string( $value ) ) { + return ''; + } + // Reject rather than truncate. Truncating mid-string produces + // invalid JSON; `parseSuggestionPayload` would then return + // null and the suggestion would silently disappear. + if ( strlen( $value ) > $max_suggestion_payload_bytes ) { + return ''; + } + return $value; + }, + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); + + // Lifecycle status for a suggestion. `pending` on creation; moved to + // `applied` or `rejected` by the apply/reject actions. + register_meta( + 'comment', + '_wp_suggestion_status', + array( + 'type' => 'string', + 'description' => __( 'Suggestion lifecycle status.', 'gutenberg' ), + 'single' => true, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'string', + 'enum' => array( 'pending', 'applied', 'rejected' ), + ), + ), + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); } add_action( 'init', 'gutenberg_register_block_comment_metadata' ); diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php index f8a19a6b490e30..45aab3bee54cf3 100644 --- a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php +++ b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php @@ -1,6 +1,31 @@ check_is_comment_content_allowed( $prepared_comment ) ) { return new WP_Error( @@ -605,6 +633,15 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { return true; } + // Allow empty content when a suggestion payload is attached. + if ( + isset( $check['comment_type'] ) && + 'note' === $check['comment_type'] && + ! empty( $check['meta']['_wp_suggestion'] ) + ) { + return true; + } + /* * Do not allow a comment to be created with missing or empty * comment_content. See wp_handle_comment_submission(). diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 48e958264e5337..c620843d8b70d0 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -5,6 +5,7 @@ ### New Features - Added an `editorIntent` preference (`edit`, `suggest`, `view`) with a matching `setEditorIntent` action and `getEditorIntent` selector. Surfaced as an Edit / Suggest / View menu in the editor options for post types that support notes. The `view` intent puts the block editor into a read-only preview. Keyboard shortcuts follow the Google Docs convention: Ctrl+Alt+Shift+Z (Edit), +X (Suggest), +C (View) on Windows / ⌘⌥⇧Z/X/C on macOS. +- Added a suggestion-overlay subsystem that powers the `suggest` intent. When active, an `editor.BlockEdit` filter diverts `setAttributes` into an in-memory overlay keyed by `clientId`; the block renders with the pending change merged on top of its real attributes, but the block-editor store stays at the baseline. A toolbar button on the selected block submits the overlay as a note comment with a `_wp_suggestion` meta payload (`schemaVersion`, `blockName`, `baseRevision`, `operations`) or discards it. Phase 2: capture only; Apply/Reject and diff rendering follow. ### Bug Fixes diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 0708561db201a3..62740b5b64aa72 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -46,6 +46,17 @@ import PatternRenameModal from '../pattern-rename-modal'; import PatternDuplicateModal from '../pattern-duplicate-modal'; import TemplatePartMenuItems from '../template-part-menu-items'; import MediaEditorModalMount from '../media/media-editor-modal'; +import { + SuggestionOverlayProvider, + SuggestionCommitBar, + SuggestionStoreInterceptor, + registerSuggestionOverlayFilter, +} from '../suggestion-mode'; + +// Register the `editor.BlockEdit` filter once when the editor provider module +// loads. The filter is a no-op outside of the `suggest` intent, so it's safe +// to register globally. +registerSuggestionOverlayFilter(); const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); const { PatternsMenuItems } = unlock( editPatternsPrivateApis ); @@ -448,29 +459,33 @@ export const ExperimentalEditorProvider = withRegistryProvider( settings={ blockEditorSettings } useSubRegistry={ false } > - { children } - { ! settings.isPreviewMode && ( - <> - - - { mode === 'template-locked' && ( - - ) } - { type === 'wp_navigation' && ( - - ) } - - - - - - - - { window?.__experimentalMediaEditorModal && ( - - ) } - - ) } + + { children } + { ! settings.isPreviewMode && ( + <> + + + { mode === 'template-locked' && ( + + ) } + { type === 'wp_navigation' && ( + + ) } + + + + + + + + + + { window?.__experimentalMediaEditorModal && ( + + ) } + + ) } + diff --git a/packages/editor/src/components/suggestion-mode/commit-bar.js b/packages/editor/src/components/suggestion-mode/commit-bar.js new file mode 100644 index 00000000000000..feeaa4905ccafa --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/commit-bar.js @@ -0,0 +1,109 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + BlockControls, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { ToolbarGroup, ToolbarButton } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { useCallback, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useSuggestionOverlay } from './overlay-context'; +import { operationsFromOverlay, useSuggestionsProvider } from './provider'; +import { EDITOR_STORE_NAME } from './constants'; + +/** + * Block toolbar group that surfaces Submit / Discard controls whenever the + * currently selected block has a pending suggestion overlay. + * + * The bar is a shared singleton — mounted once per editor provider rather + * than once per block — because the block-editor's `BlockControls` fill + * automatically targets the selected block's toolbar slot. + * + * @return {React.ReactNode|null} Toolbar markup, or null if nothing pending. + */ +export default function SuggestionCommitBar() { + const { entries, clearOverlay } = useSuggestionOverlay(); + const { createSuggestion } = useSuggestionsProvider(); + + const { selectedClientId, isSuggestMode } = useSelect( ( select ) => { + return { + selectedClientId: + select( blockEditorStore ).getSelectedBlockClientId(), + isSuggestMode: + select( EDITOR_STORE_NAME ).getEditorIntent?.() === 'suggest', + }; + }, [] ); + + const [ isSubmitting, setIsSubmitting ] = useState( false ); + const entry = selectedClientId ? entries[ selectedClientId ] : null; + const hasOverlay = + !! entry && Object.keys( entry.overlayAttributes ).length > 0; + + const onSubmit = useCallback( async () => { + if ( ! entry || isSubmitting ) { + return; + } + const operations = operationsFromOverlay( + entry.baselineAttributes, + entry.overlayAttributes + ); + if ( operations.length === 0 ) { + clearOverlay( selectedClientId ); + return; + } + setIsSubmitting( true ); + try { + await createSuggestion( { + clientId: selectedClientId, + blockName: entry.blockName, + operations, + } ); + clearOverlay( selectedClientId ); + } catch { + // Notice surfaced by the provider. + } finally { + setIsSubmitting( false ); + } + }, [ + entry, + isSubmitting, + selectedClientId, + clearOverlay, + createSuggestion, + ] ); + + const onDiscard = useCallback( () => { + if ( selectedClientId ) { + clearOverlay( selectedClientId ); + } + }, [ selectedClientId, clearOverlay ] ); + + if ( ! isSuggestMode || ! hasOverlay ) { + return null; + } + + return ( + + + + { isSubmitting + ? __( 'Submitting…' ) + : __( 'Submit suggestion' ) } + + + { __( 'Discard' ) } + + + + ); +} diff --git a/packages/editor/src/components/suggestion-mode/constants.js b/packages/editor/src/components/suggestion-mode/constants.js new file mode 100644 index 00000000000000..f2228d7c990401 --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/constants.js @@ -0,0 +1,13 @@ +/** + * The editor store is referenced by its registered name rather than being + * imported directly to avoid a module cycle between the suggestion-mode + * subsystem, the editor store, and the editor provider (which mounts + * `SuggestionOverlayProvider`). + */ +export const EDITOR_STORE_NAME = 'core/editor'; + +/** + * Mirror of the `suggest` intent value defined in the editor store's + * constants. Duplicated here to avoid the module cycle described above. + */ +export const SUGGEST_INTENT = 'suggest'; diff --git a/packages/editor/src/components/suggestion-mode/index.js b/packages/editor/src/components/suggestion-mode/index.js new file mode 100644 index 00000000000000..4af71a0e614c1f --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/index.js @@ -0,0 +1,18 @@ +export { + SuggestionOverlayProvider, + useSuggestionOverlay, + overlayReducer, +} from './overlay-context'; +export { + default as withSuggestionOverlay, + registerSuggestionOverlayFilter, +} from './with-suggestion-overlay'; +export { default as SuggestionCommitBar } from './commit-bar'; +export { default as SuggestionStoreInterceptor } from './store-interceptor'; +export { + useSuggestionsProvider, + operationsFromOverlay, + payloadByteLength, + PAYLOAD_MAX_BYTES, + SCHEMA_VERSION, +} from './provider'; diff --git a/packages/editor/src/components/suggestion-mode/overlay-context.js b/packages/editor/src/components/suggestion-mode/overlay-context.js new file mode 100644 index 00000000000000..e69d9276550fdc --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/overlay-context.js @@ -0,0 +1,272 @@ +/** + * In-memory overlay system for Suggest mode. + * + * The overlay holds user edits made while the editor is in `suggest` intent + * without ever writing them through to the block-editor store. Each entry is + * keyed by `clientId` and carries: + * - `baselineAttributes` — captured on first edit, used by + * `operationsFromOverlay` (provider.js) to build the persisted suggestion. + * - `overlayAttributes` — pending user changes; merged into the rendered + * attributes by `withSuggestionOverlay` so the user sees their edit, but + * never stored. + * + * Why an overlay rather than a draft post / branch? + * - The post stays at its real baseline so autosave, undo/redo, and + * real-time collaboration sync see only persisted state. + * - Multiple editors can suggest concurrently without conflicting writes. + * - Suggestions stay immutable until explicitly committed (`createSuggestion`), + * so a half-typed edit never leaks into the post. + * + * Overlay entry lifecycle: + * 1. `captureBaseline` — fired on first `setAttributes` (HOC) or on the first + * detected store-level mutation (store-interceptor). + * 2. `setOverlayAttributes` — accumulated by the wrapped `setAttributes` or + * by interceptor diffs. + * 3. `clearOverlay` / `PRUNE_ORPHANS` — entries are dropped when the + * suggestion is committed, rejected, or the underlying block is deleted. + * + * The orphan prune runs whenever the live block tree shrinks; it skips when + * the block-editor store isn't registered (tests, standalone consumers). + */ +/** + * WordPress dependencies + */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, +} from '@wordpress/element'; +import { useRegistry, useSelect } from '@wordpress/data'; + +// Referenced by name to keep the provider runnable in tests and standalone +// contexts where the block-editor store isn't registered. Orphan cleanup is +// skipped in those environments. +const BLOCK_EDITOR_STORE_NAME = 'core/block-editor'; + +/** + * Internal dependencies + */ + +/** + * @typedef {Object} OverlayEntry + * @property {string} blockName The block name at the time the + * overlay was opened. + * @property {Object} baselineAttributes The attributes captured when + * Suggest mode first began editing + * this block. + * @property {Object} overlayAttributes Pending attribute changes that + * have not yet been committed. + */ + +/** + * @typedef {Object} OverlayContextValue + * @property {Object.} entries Per-clientId entries. + * @property {Function} captureBaseline Store a baseline for a + * block if one isn't set. + * @property {Function} setOverlayAttributes Merge overlay attributes + * onto an entry. + * @property {Function} clearOverlay Remove the entry. + * @property {Function} hasOverlay Check if an entry has any + * overlay attributes. + */ + +const EMPTY_ENTRIES = Object.freeze( {} ); + +const OverlayContext = createContext( { + entries: EMPTY_ENTRIES, + captureBaseline: () => {}, + setOverlayAttributes: () => {}, + clearOverlay: () => {}, + hasOverlay: () => false, +} ); + +/** + * Reducer managing the map of pending block overlays. + * + * @param {Object} state Current state. + * @param {Object} action Action. + * @return {Object} Next state. + */ +export function overlayReducer( state, action ) { + switch ( action.type ) { + case 'CAPTURE_BASELINE': { + if ( state[ action.clientId ] ) { + return state; + } + return { + ...state, + [ action.clientId ]: { + blockName: action.blockName, + baselineAttributes: action.attributes, + overlayAttributes: {}, + }, + }; + } + case 'SET_OVERLAY_ATTRIBUTES': { + const entry = state[ action.clientId ]; + if ( ! entry ) { + return state; + } + return { + ...state, + [ action.clientId ]: { + ...entry, + overlayAttributes: { + ...entry.overlayAttributes, + ...action.attributes, + }, + }, + }; + } + case 'CLEAR_OVERLAY': { + if ( ! state[ action.clientId ] ) { + return state; + } + const { [ action.clientId ]: _removed, ...rest } = state; + return rest; + } + case 'PRUNE_ORPHANS': { + // Action carries a serializable array; the reducer materializes a + // Set internally for the lookup. Keeps actions Redux-DevTools- + // friendly (Sets aren't serializable for time-travel). + const liveIds = Array.isArray( action.liveClientIds ) + ? new Set( action.liveClientIds ) + : action.liveClientIds; + const keys = Object.keys( state ); + let changed = false; + const next = {}; + for ( const key of keys ) { + if ( liveIds.has( key ) ) { + next[ key ] = state[ key ]; + } else { + changed = true; + } + } + return changed ? next : state; + } + default: + return state; + } +} + +/** + * Provider exposing the suggestion overlay to descendant blocks. + * + * The overlay is intentionally in-memory only. It stores pending attribute + * changes per `clientId` so a block can render the user's in-progress + * suggestion without mutating the real block-editor state. + * + * @param {{ children: React.ReactNode }} props + */ +export function SuggestionOverlayProvider( { children } ) { + const [ entries, dispatch ] = useReducer( overlayReducer, EMPTY_ENTRIES ); + const registry = useRegistry(); + + const captureBaseline = useCallback( + ( clientId, blockName, attributes ) => + dispatch( { + type: 'CAPTURE_BASELINE', + clientId, + blockName, + attributes, + } ), + [] + ); + + const setOverlayAttributes = useCallback( + ( clientId, attributes ) => + dispatch( { + type: 'SET_OVERLAY_ATTRIBUTES', + clientId, + attributes, + } ), + [] + ); + + const clearOverlay = useCallback( + ( clientId ) => dispatch( { type: 'CLEAR_OVERLAY', clientId } ), + [] + ); + + const hasEntries = Object.keys( entries ).length > 0; + + const hasOverlay = useCallback( + ( clientId ) => { + const entry = entries[ clientId ]; + return ( + !! entry && Object.keys( entry.overlayAttributes ).length > 0 + ); + }, + [ entries ] + ); + + // Prune overlay entries whose block was removed from the editor. This + // prevents stale baselines from persisting after a block is deleted. + // The block-count subscription only runs when there are entries to + // prune; in Edit / View intent (no entries) there's no point watching + // the block tree at all. + const blockCount = useSelect( + ( select ) => { + if ( ! hasEntries ) { + return 0; + } + const blockEditor = select( BLOCK_EDITOR_STORE_NAME ); + return blockEditor?.getClientIdsWithDescendants?.().length ?? 0; + }, + [ hasEntries ] + ); + useEffect( () => { + if ( ! hasEntries ) { + return; + } + const getLive = registry.select( + BLOCK_EDITOR_STORE_NAME + )?.getClientIdsWithDescendants; + if ( ! getLive ) { + return; + } + const live = getLive(); + const liveSet = new Set( live ); + const hasOrphan = Object.keys( entries ).some( + ( key ) => ! liveSet.has( key ) + ); + if ( hasOrphan ) { + dispatch( { type: 'PRUNE_ORPHANS', liveClientIds: live } ); + } + }, [ hasEntries, blockCount, entries, registry ] ); + + const value = useMemo( + () => ( { + entries, + captureBaseline, + setOverlayAttributes, + clearOverlay, + hasOverlay, + } ), + [ + entries, + captureBaseline, + setOverlayAttributes, + clearOverlay, + hasOverlay, + ] + ); + + return ( + + { children } + + ); +} + +/** + * Hook returning the suggestion overlay API. + * + * @return {OverlayContextValue} Overlay API. + */ +export function useSuggestionOverlay() { + return useContext( OverlayContext ); +} diff --git a/packages/editor/src/components/suggestion-mode/provider.js b/packages/editor/src/components/suggestion-mode/provider.js new file mode 100644 index 00000000000000..75bf6bb4491939 --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/provider.js @@ -0,0 +1,279 @@ +/** + * WordPress dependencies + */ +import { useCallback } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as noticesStore } from '@wordpress/notices'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { EDITOR_STORE_NAME } from './constants'; + +/** + * @typedef {Object} SuggestionOperation + * @property {'attribute-set'} type Operation type. Only `attribute-set` + * is implemented in Phase 2. + * @property {string} attribute The attribute being changed. + * @property {*} before The baseline value. + * @property {*} after The proposed value. + */ + +/** + * @typedef {Object} SuggestionPayload + * @property {number} schemaVersion Payload schema version. + * @property {string} blockName Block name at capture time. + * @property {string|null} baseRevision Post `modified_gmt` at + * capture, used by Phase 3 to + * detect stale suggestions. + * @property {SuggestionOperation[]} operations Ordered operations. + */ + +const SCHEMA_VERSION = 1; + +/** + * Maximum byte length of a serialized suggestion payload. Mirrors + * `GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES` in + * `lib/compat/wordpress-6.9/block-comments.php`. The client checks before + * submitting so a doomed request never leaves the browser; the REST + * controller is the authoritative gate. + */ +const PAYLOAD_MAX_BYTES = 65536; + +/** + * Byte length of a serialized payload, measured the way PHP `strlen()` + * counts (UTF-8 bytes, not chars). + * + * @param {SuggestionPayload} payload + * @return {number} UTF-8 byte length of the serialized JSON. + */ +function payloadByteLength( payload ) { + const serialized = JSON.stringify( payload ); + if ( typeof TextEncoder !== 'undefined' ) { + return new TextEncoder().encode( serialized ).length; + } + // Conservative upper bound: 4 bytes per UTF-16 code unit covers all + // possible UTF-8 expansions. Used only in test/JSDOM environments + // without TextEncoder. + return serialized.length * 4; +} + +/** + * Build attribute-set operations by diffing an overlay entry against its + * captured baseline. Attributes whose value differs are emitted; unchanged + * or absent keys are skipped. + * + * @param {Object} baselineAttributes Attributes captured on first edit. + * @param {Object} overlayAttributes Pending attribute changes. + * @return {SuggestionOperation[]} Operations describing the suggestion. + */ +export function operationsFromOverlay( baselineAttributes, overlayAttributes ) { + const operations = []; + for ( const [ attribute, after ] of Object.entries( + overlayAttributes || {} + ) ) { + const before = baselineAttributes?.[ attribute ]; + if ( ! isAttributeEqual( before, after ) ) { + operations.push( { + type: 'attribute-set', + attribute, + before: before ?? null, + after, + } ); + } + } + return operations; +} + +/** + * Structural equality for attribute values. Handles primitives, arrays, and + * plain objects with arbitrary key order. + * + * `JSON.stringify` is order-sensitive ({a:1,b:2} ≠ {b:2,a:1}), so a stringify- + * based compare produces spurious "changed" detections when block code re- + * emits a `style` object with reordered keys. The recursive walk avoids that. + * + * @param {*} a First value. + * @param {*} b Second value. + * @return {boolean} True when the values are structurally equal. + */ +function isAttributeEqual( a, b ) { + if ( a === b ) { + return true; + } + if ( a === null || a === undefined || b === null || b === undefined ) { + return false; + } + if ( typeof a !== 'object' || typeof b !== 'object' ) { + return false; + } + const aIsArray = Array.isArray( a ); + const bIsArray = Array.isArray( b ); + if ( aIsArray !== bIsArray ) { + return false; + } + if ( aIsArray ) { + if ( a.length !== b.length ) { + return false; + } + for ( let i = 0; i < a.length; i++ ) { + if ( ! isAttributeEqual( a[ i ], b[ i ] ) ) { + return false; + } + } + return true; + } + const aKeys = Object.keys( a ); + const bKeys = Object.keys( b ); + if ( aKeys.length !== bKeys.length ) { + return false; + } + for ( const key of aKeys ) { + if ( ! Object.prototype.hasOwnProperty.call( b, key ) ) { + return false; + } + if ( ! isAttributeEqual( a[ key ], b[ key ] ) ) { + return false; + } + } + return true; +} + +/** + * Comment-meta backed suggestions provider. Phase 2 implements only + * `createSuggestion`. Apply and reject are stubbed and will be implemented + * alongside the diff preview in Phase 3. The provider shape is stable so + * Phase 3 / a future Yjs-backed provider can swap in without touching the + * UI. + * + * Storage: a new `note` comment with the suggestion payload serialized to + * the `_wp_suggestion` comment meta. Linkage to a block reuses the existing + * `metadata.noteId` block attribute. + * + * @return {{ + * createSuggestion: Function, + * applySuggestion: Function, + * rejectSuggestion: Function, + * }} Suggestions API. + */ +export function useSuggestionsProvider() { + const { postId, postModified } = useSelect( ( select ) => { + const editor = select( EDITOR_STORE_NAME ); + const id = editor?.getCurrentPostId?.() ?? null; + const postType = editor?.getCurrentPostType?.() ?? null; + const record = + id && postType + ? select( coreStore ).getEditedEntityRecord( + 'postType', + postType, + id + ) + : null; + return { + postId: id, + postModified: record?.modified_gmt ?? null, + }; + }, [] ); + + const { saveEntityRecord } = useDispatch( coreStore ); + const { createNotice } = useDispatch( noticesStore ); + const { updateBlockAttributes } = useDispatch( blockEditorStore ); + const { getBlockAttributes: selectBlockAttributes } = + useSelect( blockEditorStore ); + + const createSuggestion = useCallback( + async ( { clientId, blockName, operations } ) => { + if ( ! postId ) { + throw new Error( 'No post id available for suggestion.' ); + } + if ( ! operations || operations.length === 0 ) { + return null; + } + + const payload = /** @type {SuggestionPayload} */ ( { + schemaVersion: SCHEMA_VERSION, + blockName, + baseRevision: postModified, + operations, + } ); + + if ( payloadByteLength( payload ) > PAYLOAD_MAX_BYTES ) { + const error = new Error( + __( 'Suggestion is too large to save.' ) + ); + createNotice( 'error', error.message, { + type: 'snackbar', + isDismissible: true, + } ); + throw error; + } + + try { + const savedRecord = await saveEntityRecord( + 'root', + 'comment', + { + post: postId, + content: '', + status: 'hold', + type: 'note', + parent: 0, + meta: { + _wp_suggestion: JSON.stringify( payload ), + }, + }, + { throwOnError: true } + ); + + if ( savedRecord?.id ) { + // Merge into existing metadata rather than replacing so + // other fields like bindings, name, and block identifiers + // are preserved. + const existingMeta = + selectBlockAttributes( clientId )?.metadata ?? {}; + updateBlockAttributes( clientId, { + metadata: { + ...existingMeta, + noteId: savedRecord.id, + }, + } ); + } + + createNotice( 'success', __( 'Suggestion submitted.' ), { + type: 'snackbar', + isDismissible: true, + } ); + return savedRecord; + } catch ( error ) { + createNotice( + 'error', + error?.message || __( 'Unable to submit suggestion.' ), + { type: 'snackbar', isDismissible: true } + ); + throw error; + } + }, + [ + postId, + postModified, + saveEntityRecord, + updateBlockAttributes, + selectBlockAttributes, + createNotice, + ] + ); + + const applySuggestion = useCallback( async () => { + throw new Error( 'applySuggestion is not implemented in Phase 2.' ); + }, [] ); + const rejectSuggestion = useCallback( async () => { + throw new Error( 'rejectSuggestion is not implemented in Phase 2.' ); + }, [] ); + + return { createSuggestion, applySuggestion, rejectSuggestion }; +} + +export { SCHEMA_VERSION, PAYLOAD_MAX_BYTES, payloadByteLength }; diff --git a/packages/editor/src/components/suggestion-mode/store-interceptor.js b/packages/editor/src/components/suggestion-mode/store-interceptor.js new file mode 100644 index 00000000000000..8f83bd2e0d73d9 --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/store-interceptor.js @@ -0,0 +1,405 @@ +/** + * Store-level safety net for Suggest mode. + * + * `withSuggestionOverlay` already diverts attribute changes that go through a + * block's `setAttributes` prop. This file handles the other half: mutations + * that bypass the prop chain by dispatching `updateBlockAttributes` directly + * to the block-editor store. The block-switcher (heading variation H2 → H3) + * and any third-party code that uses the data store API land here. + * + * Strategy (see `SuggestionStoreInterceptor` for the fully-commented flow): + * - On Suggest activation, snapshot every block's attributes. + * - Subscribe to the registry. When a dispatch lands the subscribe fires; + * diff the live attributes against the snapshot. + * - For drift on a tracked block: route the changed attributes into the + * overlay and dispatch a revert that restores the snapshot. + * - The `isReverting` flag suppresses the recursive subscribe fire that + * the revert dispatch itself would otherwise trigger. + * - System-managed metadata (`metadata.noteId` written by the suggestion + * provider after creating a note comment) is folded into the snapshot + * before diffing so it's invisible to the diff and never leaks into the + * user-pending overlay. + * + * Why subscribe rather than React state: + * - The interceptor must run after the dispatch lands but before any + * React re-render serializes the (now wrong) state. `registry.subscribe` + * fires synchronously immediately after every store update. + * - Subscribe also catches dispatches from non-React paths (CLI scripts, + * keyboard handlers, external integrations) that wouldn't trigger a + * `useSelect`-based watcher. + * + * New blocks (no snapshot entry) are tracked but not intercepted — inserting + * a block in Suggest mode is currently a real edit, not a suggestion. + */ +/** + * WordPress dependencies + */ +import { useRegistry, useSelect } from '@wordpress/data'; +import { useEffect, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useSuggestionOverlay } from './overlay-context'; +import { EDITOR_STORE_NAME, SUGGEST_INTENT } from './constants'; + +const BLOCK_EDITOR_STORE_NAME = 'core/block-editor'; + +/** + * Keys under `metadata` that are programmatic linkages set by editor-internal + * code (the suggestion provider writes `metadata.noteId` after creating a + * note comment to link the block back to its note). These must persist on + * the live block — without the linkage, `useNoteThreads` can't resolve a + * note's `blockClientId` and the note appears orphaned. The interceptor + * folds changes to these keys into its snapshot before diffing so they are + * never reverted and never routed into the user-pending overlay. + */ +const SYSTEM_METADATA_KEYS = new Set( [ 'noteId' ] ); + +/** + * Compare two attribute values structurally. Mirrors `isAttributeEqual` in + * provider.js — kept as a private helper here so this module doesn't pull + * in the provider's hooks just for the comparison. + * + * @param {*} a First value. + * @param {*} b Second value. + * @return {boolean} True when the two values are structurally equal. + */ +function shallowAttributeEquals( a, b ) { + if ( a === b ) { + return true; + } + if ( a === null || a === undefined || b === null || b === undefined ) { + return false; + } + if ( typeof a !== 'object' || typeof b !== 'object' ) { + return false; + } + const aIsArray = Array.isArray( a ); + const bIsArray = Array.isArray( b ); + if ( aIsArray !== bIsArray ) { + return false; + } + if ( aIsArray ) { + if ( a.length !== b.length ) { + return false; + } + for ( let i = 0; i < a.length; i++ ) { + if ( ! shallowAttributeEquals( a[ i ], b[ i ] ) ) { + return false; + } + } + return true; + } + const aKeys = Object.keys( a ); + const bKeys = Object.keys( b ); + if ( aKeys.length !== bKeys.length ) { + return false; + } + for ( const key of aKeys ) { + if ( ! Object.prototype.hasOwnProperty.call( b, key ) ) { + return false; + } + if ( ! shallowAttributeEquals( a[ key ], b[ key ] ) ) { + return false; + } + } + return true; +} + +/** + * Diff two attribute objects, returning a map of `{ key: currentValue }` for + * keys whose value has changed and `{ key: previousValue }` for the keys that + * need to be restored on the block. + * + * @param {Object} previous Attributes before the mutation. + * @param {Object} current Attributes after the mutation. + * @return {{ changed: Object, restore: Object }|null} Per-key delta, or null + * when no keys changed. + */ +function diffAttributes( previous, current ) { + const changed = {}; + const restore = {}; + let hasChange = false; + const seen = new Set(); + + for ( const key of Object.keys( current ) ) { + seen.add( key ); + const prevValue = previous?.[ key ]; + const currValue = current[ key ]; + if ( ! shallowAttributeEquals( prevValue, currValue ) ) { + changed[ key ] = currValue; + restore[ key ] = prevValue ?? undefined; + hasChange = true; + } + } + + for ( const key of Object.keys( previous ?? {} ) ) { + if ( seen.has( key ) ) { + continue; + } + // Key was removed by the mutation. + changed[ key ] = undefined; + restore[ key ] = previous[ key ]; + hasChange = true; + } + + return hasChange ? { changed, restore } : null; +} + +/** + * Fold the values of system-managed metadata keys from `current` into a copy + * of `previous`. The result is used as the snapshot baseline for the next + * diff so a programmatic update to (e.g.) `metadata.noteId` is invisible to + * the diff and the revert payload preserves the new value. + * + * Returns `previous` unchanged when no system key has drifted. + * + * @param {Object} previous Snapshot attributes. + * @param {Object} current Live attributes. + * @return {Object} Snapshot attributes with system metadata adopted. + */ +function adoptSystemMetadata( previous, current ) { + const previousMeta = previous?.metadata ?? {}; + const currentMeta = current?.metadata ?? {}; + let nextMeta = previousMeta; + let touched = false; + for ( const key of SYSTEM_METADATA_KEYS ) { + const prevValue = previousMeta[ key ]; + const currValue = currentMeta[ key ]; + if ( prevValue === currValue ) { + continue; + } + if ( ! touched ) { + nextMeta = { ...previousMeta }; + touched = true; + } + if ( currValue === undefined ) { + delete nextMeta[ key ]; + } else { + nextMeta[ key ] = currValue; + } + } + if ( ! touched ) { + return previous; + } + return { + ...previous, + metadata: nextMeta, + }; +} + +/** + * Strip system-managed metadata keys from a `changed` payload destined for + * the suggestion overlay. The overlay represents user-pending edits; system + * fields such as `metadata.noteId` must never appear there because they are + * not part of the user's suggestion and would otherwise leak into the + * persisted suggestion operations. + * + * Drops the `metadata` key entirely when no non-system fields remain. + * + * @param {Object} changed `delta.changed` from `diffAttributes`. + * @return {Object} Filtered payload safe for the overlay. + */ +function stripSystemMetadata( changed ) { + const meta = changed?.metadata; + if ( ! meta || typeof meta !== 'object' ) { + return changed; + } + let stripped = meta; + let touched = false; + for ( const key of SYSTEM_METADATA_KEYS ) { + if ( Object.prototype.hasOwnProperty.call( meta, key ) ) { + if ( ! touched ) { + stripped = { ...meta }; + touched = true; + } + delete stripped[ key ]; + } + } + if ( ! touched ) { + return changed; + } + if ( Object.keys( stripped ).length === 0 ) { + const { metadata: _drop, ...rest } = changed; + return rest; + } + return { ...changed, metadata: stripped }; +} + +/** + * Invisible component that catches block-attribute mutations dispatched + * directly to the block-editor store while the editor is in Suggest intent. + * + * The `editor.BlockEdit` HOC already intercepts `setAttributes` calls that + * blocks make through their own props. But some Gutenberg paths bypass that + * prop chain — most notably the block-switcher's variation picker, which + * calls `updateBlockAttributes( clientId, { level } )` directly to swap a + * heading from H2 → H3. Without this interceptor those mutations would land + * in the post unmodified, defeating Suggest mode. + * + * Strategy: snapshot every block's attributes when Suggest intent activates, + * then on every block-editor state change diff the live tree against the + * snapshot. Any block whose attributes drift from the snapshot has its + * change re-routed into the overlay and the live attributes restored to the + * snapshot. New blocks (no snapshot entry) are tracked but not intercepted — + * inserting a block in Suggest mode is currently a real edit, not a + * suggestion. Removed blocks are dropped from the snapshot. + * + * @return {null} Renders nothing. + */ +export default function SuggestionStoreInterceptor() { + const { entries, captureBaseline, setOverlayAttributes } = + useSuggestionOverlay(); + const registry = useRegistry(); + + const isSuggestMode = useSelect( + ( select ) => + select( EDITOR_STORE_NAME ).getEditorIntent() === SUGGEST_INTENT, + [] + ); + + // Mutable references read from inside the subscribe callback. Using refs + // avoids resubscribing on every entries / overlay change. + const entriesRef = useRef( entries ); + entriesRef.current = entries; + + const captureBaselineRef = useRef( captureBaseline ); + captureBaselineRef.current = captureBaseline; + + const setOverlayAttributesRef = useRef( setOverlayAttributes ); + setOverlayAttributesRef.current = setOverlayAttributes; + + useEffect( () => { + if ( ! isSuggestMode ) { + return undefined; + } + + const blockEditor = registry.select( BLOCK_EDITOR_STORE_NAME ); + const blockEditorDispatch = registry.dispatch( + BLOCK_EDITOR_STORE_NAME + ); + if ( ! blockEditor || ! blockEditorDispatch ) { + return undefined; + } + + // Snapshot of every block's attributes at the moment Suggest mode + // activated. New blocks added during the session are slotted in as + // they appear; mutations on existing blocks are reverted + overlaid. + const snapshot = new Map(); + const seedClientIds = blockEditor.getClientIdsWithDescendants?.() ?? []; + for ( const clientId of seedClientIds ) { + snapshot.set( + clientId, + blockEditor.getBlockAttributes( clientId ) + ); + } + + // Set true while we're calling `updateBlockAttributes` to revert a + // detected mutation, so the resulting subscribe fire doesn't loop. + let isReverting = false; + + const unsubscribe = registry.subscribe( () => { + if ( isReverting ) { + return; + } + + const liveClientIds = + blockEditor.getClientIdsWithDescendants?.() ?? []; + const live = new Set( liveClientIds ); + + for ( const clientId of liveClientIds ) { + let previous = snapshot.get( clientId ); + const current = blockEditor.getBlockAttributes( clientId ); + + if ( previous === undefined ) { + // New block (inserted after Suggest activated). Track it + // but don't intercept. + snapshot.set( clientId, current ); + continue; + } + + if ( previous === current ) { + // Block-editor preserves attribute object identity for + // untouched blocks, so this short-circuit covers the + // common case cheaply. + continue; + } + + // Programmatic linkage updates (e.g. `metadata.noteId` set by + // the suggestion provider after creating the note comment) + // must persist on the live block. Folding them into the + // snapshot before diffing makes them invisible to the diff, + // keeps the revert payload from clobbering them, and prevents + // them from leaking into the user's overlay. + const adopted = adoptSystemMetadata( previous, current ); + if ( adopted !== previous ) { + snapshot.set( clientId, adopted ); + previous = adopted; + } + + const delta = diffAttributes( previous, current ); + if ( ! delta ) { + snapshot.set( clientId, current ); + continue; + } + + // Capture a baseline if one isn't already set. The HOC's + // own captureBaseline only fires for `setAttributes` calls; + // for store-level mutations we have to seed one here. + const overlayEntries = entriesRef.current; + if ( ! overlayEntries[ clientId ] ) { + const block = blockEditor.getBlock?.( clientId ); + captureBaselineRef.current( + clientId, + block?.name ?? '', + previous + ); + } + + // Route the changes into the overlay so the user still sees + // their edit, then revert the underlying store back to the + // snapshot so the post itself isn't actually modified. System + // metadata is filtered out of the overlay payload — it isn't + // a user edit, and `delta.restore` already preserves it. + const overlayChanged = stripSystemMetadata( delta.changed ); + if ( Object.keys( overlayChanged ).length > 0 ) { + setOverlayAttributesRef.current( clientId, overlayChanged ); + } + + isReverting = true; + try { + blockEditorDispatch.updateBlockAttributes( + clientId, + delta.restore + ); + } finally { + isReverting = false; + } + + // The snapshot reflects the (now-restored) baseline for this + // block; do NOT update it to `current` here. + } + + // Drop snapshot entries for blocks that were removed. + if ( snapshot.size > liveClientIds.length ) { + for ( const clientId of snapshot.keys() ) { + if ( ! live.has( clientId ) ) { + snapshot.delete( clientId ); + } + } + } + }, BLOCK_EDITOR_STORE_NAME ); + + return unsubscribe; + }, [ isSuggestMode, registry ] ); + + return null; +} + +export { + diffAttributes, + shallowAttributeEquals, + adoptSystemMetadata, + stripSystemMetadata, +}; diff --git a/packages/editor/src/components/suggestion-mode/test/overlay-context.js b/packages/editor/src/components/suggestion-mode/test/overlay-context.js new file mode 100644 index 00000000000000..7961a6d2ef28e6 --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/test/overlay-context.js @@ -0,0 +1,126 @@ +/** + * Internal dependencies + */ +import { overlayReducer } from '../overlay-context'; + +describe( 'overlayReducer', () => { + const CLIENT_ID = 'abc-123'; + const INITIAL = Object.freeze( {} ); + + it( 'captures a baseline once per client id', () => { + const afterFirst = overlayReducer( INITIAL, { + type: 'CAPTURE_BASELINE', + clientId: CLIENT_ID, + blockName: 'core/paragraph', + attributes: { content: 'Hello' }, + } ); + expect( afterFirst[ CLIENT_ID ] ).toEqual( { + blockName: 'core/paragraph', + baselineAttributes: { content: 'Hello' }, + overlayAttributes: {}, + } ); + + const afterSecond = overlayReducer( afterFirst, { + type: 'CAPTURE_BASELINE', + clientId: CLIENT_ID, + blockName: 'core/paragraph', + attributes: { content: 'CHANGED' }, + } ); + expect( afterSecond ).toBe( afterFirst ); + } ); + + it( 'merges overlay attributes over an existing entry', () => { + const withBaseline = overlayReducer( INITIAL, { + type: 'CAPTURE_BASELINE', + clientId: CLIENT_ID, + blockName: 'core/paragraph', + attributes: { content: 'A', level: 2 }, + } ); + const withOverlay = overlayReducer( withBaseline, { + type: 'SET_OVERLAY_ATTRIBUTES', + clientId: CLIENT_ID, + attributes: { content: 'B' }, + } ); + expect( withOverlay[ CLIENT_ID ].overlayAttributes ).toEqual( { + content: 'B', + } ); + expect( withOverlay[ CLIENT_ID ].baselineAttributes ).toEqual( { + content: 'A', + level: 2, + } ); + + const updated = overlayReducer( withOverlay, { + type: 'SET_OVERLAY_ATTRIBUTES', + clientId: CLIENT_ID, + attributes: { level: 3 }, + } ); + expect( updated[ CLIENT_ID ].overlayAttributes ).toEqual( { + content: 'B', + level: 3, + } ); + } ); + + it( 'ignores overlay writes without a captured baseline', () => { + const next = overlayReducer( INITIAL, { + type: 'SET_OVERLAY_ATTRIBUTES', + clientId: CLIENT_ID, + attributes: { content: 'Nope' }, + } ); + expect( next ).toBe( INITIAL ); + } ); + + it( 'removes an entry on clear', () => { + const withEntry = overlayReducer( INITIAL, { + type: 'CAPTURE_BASELINE', + clientId: CLIENT_ID, + blockName: 'core/paragraph', + attributes: {}, + } ); + const cleared = overlayReducer( withEntry, { + type: 'CLEAR_OVERLAY', + clientId: CLIENT_ID, + } ); + expect( cleared ).toEqual( {} ); + expect( cleared ).not.toBe( withEntry ); + } ); + + it( 'returns the same reference for unknown actions', () => { + const next = overlayReducer( INITIAL, { type: 'UNKNOWN' } ); + expect( next ).toBe( INITIAL ); + } ); + + it( 'prunes entries whose clientId is no longer live', () => { + const state = { + 'alive-1': { + blockName: 'core/paragraph', + baselineAttributes: {}, + overlayAttributes: { content: 'hi' }, + }, + 'orphan-1': { + blockName: 'core/paragraph', + baselineAttributes: {}, + overlayAttributes: { content: 'gone' }, + }, + }; + const next = overlayReducer( state, { + type: 'PRUNE_ORPHANS', + liveClientIds: new Set( [ 'alive-1' ] ), + } ); + expect( Object.keys( next ) ).toEqual( [ 'alive-1' ] ); + } ); + + it( 'returns the same reference when no orphans are present', () => { + const state = { + 'alive-1': { + blockName: 'core/paragraph', + baselineAttributes: {}, + overlayAttributes: {}, + }, + }; + const next = overlayReducer( state, { + type: 'PRUNE_ORPHANS', + liveClientIds: new Set( [ 'alive-1', 'other' ] ), + } ); + expect( next ).toBe( state ); + } ); +} ); diff --git a/packages/editor/src/components/suggestion-mode/test/provider.js b/packages/editor/src/components/suggestion-mode/test/provider.js new file mode 100644 index 00000000000000..4540e4d2cf1519 --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/test/provider.js @@ -0,0 +1,113 @@ +/** + * Internal dependencies + */ +import { + operationsFromOverlay, + payloadByteLength, + PAYLOAD_MAX_BYTES, +} from '../provider'; + +describe( 'operationsFromOverlay', () => { + it( 'emits one attribute-set op per changed key', () => { + const ops = operationsFromOverlay( + { content: 'Hello', level: 2 }, + { content: 'Hi', level: 3 } + ); + expect( ops ).toEqual( [ + { + type: 'attribute-set', + attribute: 'content', + before: 'Hello', + after: 'Hi', + }, + { + type: 'attribute-set', + attribute: 'level', + before: 2, + after: 3, + }, + ] ); + } ); + + it( 'skips attributes that equal their baseline', () => { + const ops = operationsFromOverlay( + { content: 'Same', level: 2 }, + { content: 'Same', level: 3 } + ); + expect( ops ).toEqual( [ + { + type: 'attribute-set', + attribute: 'level', + before: 2, + after: 3, + }, + ] ); + } ); + + it( 'deep-compares object-valued attributes', () => { + const ops = operationsFromOverlay( + { style: { typography: { fontSize: '16px' } } }, + { style: { typography: { fontSize: '16px' } } } + ); + expect( ops ).toEqual( [] ); + } ); + + it( 'is insensitive to key order in object-valued attributes', () => { + // `style` re-emitted with reordered keys must not appear as a + // changed attribute. A naive JSON.stringify compare would flag it. + const ops = operationsFromOverlay( + { style: { typography: { fontSize: '16px' }, color: 'red' } }, + { style: { color: 'red', typography: { fontSize: '16px' } } } + ); + expect( ops ).toEqual( [] ); + } ); + + it( 'compares arrays element-wise', () => { + expect( + operationsFromOverlay( + { classes: [ 'a', 'b' ] }, + { classes: [ 'a', 'b' ] } + ) + ).toEqual( [] ); + const ops = operationsFromOverlay( + { classes: [ 'a', 'b' ] }, + { classes: [ 'b', 'a' ] } + ); + expect( ops ).toHaveLength( 1 ); + expect( ops[ 0 ].attribute ).toBe( 'classes' ); + } ); + + it( 'captures a null baseline when the attribute is new', () => { + const ops = operationsFromOverlay( {}, { url: 'https://x.test' } ); + expect( ops ).toEqual( [ + { + type: 'attribute-set', + attribute: 'url', + before: null, + after: 'https://x.test', + }, + ] ); + } ); + + it( 'returns an empty array for an empty overlay', () => { + expect( operationsFromOverlay( { a: 1 }, {} ) ).toEqual( [] ); + expect( operationsFromOverlay( { a: 1 }, null ) ).toEqual( [] ); + } ); +} ); + +describe( 'payloadByteLength', () => { + it( 'measures ASCII payload byte length', () => { + // {"a":"hello"} is 13 bytes. + expect( payloadByteLength( { a: 'hello' } ) ).toBe( 13 ); + } ); + + it( 'counts multi-byte characters by UTF-8 byte length', () => { + // {"a":"€"} = 8 ASCII bytes + 3 bytes for the euro sign = 11. + expect( payloadByteLength( { a: '€' } ) ).toBe( 11 ); + } ); + + it( 'exposes a numeric size cap', () => { + expect( PAYLOAD_MAX_BYTES ).toBeGreaterThan( 0 ); + expect( typeof PAYLOAD_MAX_BYTES ).toBe( 'number' ); + } ); +} ); diff --git a/packages/editor/src/components/suggestion-mode/test/store-interceptor.js b/packages/editor/src/components/suggestion-mode/test/store-interceptor.js new file mode 100644 index 00000000000000..2bf87a68088856 --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/test/store-interceptor.js @@ -0,0 +1,359 @@ +/** + * External dependencies + */ +import { render, act } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { createRegistry, RegistryProvider } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as noticesStore } from '@wordpress/notices'; +import { store as preferencesStore } from '@wordpress/preferences'; +import { + createBlock, + registerBlockType, + unregisterBlockType, + getBlockTypes, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import SuggestionStoreInterceptor, { + diffAttributes, + shallowAttributeEquals, + adoptSystemMetadata, + stripSystemMetadata, +} from '../store-interceptor'; +import { + SuggestionOverlayProvider, + useSuggestionOverlay, +} from '../overlay-context'; +import { store as editorStore } from '../../../store'; + +describe( 'shallowAttributeEquals', () => { + it( 'treats reference-equal values as equal', () => { + const obj = { a: 1 }; + expect( shallowAttributeEquals( obj, obj ) ).toBe( true ); + } ); + + it( 'returns true for primitives that match', () => { + expect( shallowAttributeEquals( 1, 1 ) ).toBe( true ); + expect( shallowAttributeEquals( 'a', 'a' ) ).toBe( true ); + } ); + + it( 'returns false when only one side is null/undefined', () => { + expect( shallowAttributeEquals( null, {} ) ).toBe( false ); + expect( shallowAttributeEquals( undefined, '' ) ).toBe( false ); + } ); + + it( 'compares plain objects by structure', () => { + expect( shallowAttributeEquals( { a: 1, b: 2 }, { a: 1, b: 2 } ) ).toBe( + true + ); + } ); +} ); + +describe( 'diffAttributes', () => { + it( 'returns null when attributes are unchanged', () => { + expect( + diffAttributes( { content: 'a' }, { content: 'a' } ) + ).toBeNull(); + } ); + + it( 'detects changed values and emits matching restore', () => { + expect( diffAttributes( { level: 2 }, { level: 3 } ) ).toEqual( { + changed: { level: 3 }, + restore: { level: 2 }, + } ); + } ); + + it( 'detects added keys (no previous value to restore)', () => { + expect( diffAttributes( {}, { url: 'https://example.test' } ) ).toEqual( + { + changed: { url: 'https://example.test' }, + restore: { url: undefined }, + } + ); + } ); + + it( 'detects removed keys and includes them in restore', () => { + expect( diffAttributes( { align: 'center' }, {} ) ).toEqual( { + changed: { align: undefined }, + restore: { align: 'center' }, + } ); + } ); + + it( 'collects multiple changes in one delta', () => { + const delta = diffAttributes( + { level: 2, content: 'Hi' }, + { level: 3, content: 'Hi' } + ); + expect( delta ).toEqual( { + changed: { level: 3 }, + restore: { level: 2 }, + } ); + } ); +} ); + +describe( 'adoptSystemMetadata', () => { + it( 'returns the same reference when no system metadata key changed', () => { + const previous = { content: 'Hi', metadata: { name: 'Greeting' } }; + const current = { content: 'Hi', metadata: { name: 'Greeting' } }; + expect( adoptSystemMetadata( previous, current ) ).toBe( previous ); + } ); + + it( 'folds a newly-added noteId into the snapshot', () => { + const previous = { content: 'Hi', metadata: {} }; + const current = { content: 'Hi', metadata: { noteId: 42 } }; + const adopted = adoptSystemMetadata( previous, current ); + expect( adopted ).not.toBe( previous ); + expect( adopted ).toEqual( { + content: 'Hi', + metadata: { noteId: 42 }, + } ); + } ); + + it( 'folds a changed noteId into the snapshot', () => { + const previous = { metadata: { noteId: 1 } }; + const current = { metadata: { noteId: 2 } }; + expect( adoptSystemMetadata( previous, current ) ).toEqual( { + metadata: { noteId: 2 }, + } ); + } ); + + it( 'removes noteId when current no longer has one', () => { + const previous = { metadata: { noteId: 7, name: 'Keep' } }; + const current = { metadata: { name: 'Keep' } }; + expect( adoptSystemMetadata( previous, current ) ).toEqual( { + metadata: { name: 'Keep' }, + } ); + } ); + + it( 'preserves user-managed metadata keys when adopting noteId', () => { + const previous = { metadata: { name: 'Greeting' } }; + const current = { metadata: { name: 'Greeting', noteId: 9 } }; + expect( adoptSystemMetadata( previous, current ) ).toEqual( { + metadata: { name: 'Greeting', noteId: 9 }, + } ); + } ); + + it( 'never folds non-system metadata keys', () => { + const previous = { metadata: { bindings: { a: 1 } } }; + const current = { metadata: { bindings: { b: 2 } } }; + // `bindings` is a user-managed key — should be left alone for the + // regular diff to handle. + expect( adoptSystemMetadata( previous, current ) ).toBe( previous ); + } ); +} ); + +describe( 'SuggestionStoreInterceptor (integration)', () => { + const TEST_BLOCK_NAME = 'core/test-suggestion-block'; + + beforeAll( () => { + registerBlockType( TEST_BLOCK_NAME, { + apiVersion: 3, + attributes: { + content: { type: 'string', default: '' }, + metadata: { type: 'object' }, + }, + save: () => null, + category: 'text', + title: 'Test Suggestion Block', + } ); + } ); + + afterAll( () => { + getBlockTypes().forEach( ( block ) => + unregisterBlockType( block.name ) + ); + } ); + + function setup() { + const registry = createRegistry(); + registry.register( noticesStore ); + // `preferencesStore` is required by `setEditorIntent` on branches + // where the intent is persisted as a preference; later branches + // switched to in-memory session state and ignore it. Registering + // it here keeps the test portable across the stacked PR set. + registry.register( preferencesStore ); + registry.register( blockEditorStore ); + registry.register( editorStore ); + registry.dispatch( editorStore ).setEditorIntent( 'suggest' ); + + const block = createBlock( TEST_BLOCK_NAME, { content: 'Hello' } ); + registry.dispatch( blockEditorStore ).resetBlocks( [ block ] ); + + let overlayHandle; + function CaptureOverlay() { + overlayHandle = useSuggestionOverlay(); + return null; + } + + const wrapper = ( { children } ) => ( + + + { children } + + + ); + + render( + <> + + + , + { wrapper } + ); + + return { + registry, + clientId: block.clientId, + getOverlay: () => overlayHandle, + }; + } + + async function flushSubscribers() { + // `registry.subscribe` callbacks are scheduled asynchronously after + // dispatches; one microtask flush is enough for the interceptor's + // post-dispatch reaction to run. + await act( async () => { + await Promise.resolve(); + } ); + } + + it( 'preserves a programmatic metadata.noteId update on the live block', async () => { + // This is the regression scenario from the suggestion provider: + // after creating a note comment it calls + // `updateBlockAttributes(clientId, { metadata: { noteId } })` to + // link the block to its note. Without the fix, the interceptor + // reverted that update and the note appeared orphaned in the sidebar. + const { registry, clientId, getOverlay } = setup(); + + await act( async () => { + registry + .dispatch( blockEditorStore ) + .updateBlockAttributes( clientId, { + metadata: { noteId: 42 }, + } ); + } ); + await flushSubscribers(); + + const liveAttributes = registry + .select( blockEditorStore ) + .getBlockAttributes( clientId ); + + expect( liveAttributes?.metadata?.noteId ).toBe( 42 ); + // The system update must NOT leak into the overlay. + expect( + getOverlay().entries[ clientId ]?.overlayAttributes?.metadata + ).toBeUndefined(); + } ); + + it( 'preserves metadata.noteId while still intercepting other attribute changes', async () => { + const { registry, clientId, getOverlay } = setup(); + + // First, link the block to a note (as the suggestion provider would). + await act( async () => { + registry + .dispatch( blockEditorStore ) + .updateBlockAttributes( clientId, { metadata: { noteId: 7 } } ); + } ); + await flushSubscribers(); + + // Then simulate a direct user-driven mutation of `content` (the kind + // of bypass the interceptor was added for, e.g. a block-switcher + // variation picker calling `updateBlockAttributes` directly). + await act( async () => { + registry + .dispatch( blockEditorStore ) + .updateBlockAttributes( clientId, { content: 'Edited' } ); + } ); + await flushSubscribers(); + + const liveAttributes = registry + .select( blockEditorStore ) + .getBlockAttributes( clientId ); + + // noteId is preserved on the live block (system linkage). + expect( liveAttributes?.metadata?.noteId ).toBe( 7 ); + // The user content edit is reverted to the baseline. + expect( liveAttributes?.content ).toBe( 'Hello' ); + // The user content edit IS captured in the overlay. + expect( + getOverlay().entries[ clientId ]?.overlayAttributes?.content + ).toBe( 'Edited' ); + // noteId never appears in the overlay. + expect( + getOverlay().entries[ clientId ]?.overlayAttributes?.metadata + ).toBeUndefined(); + } ); + + it( 'preserves metadata.noteId when reverting a same-tick combined mutation', async () => { + const { registry, clientId, getOverlay } = setup(); + + // A direct dispatch that touches both `content` (user-style) and + // `metadata.noteId` (system-style) at the same time. The interceptor + // must keep the noteId on the live block while still routing the + // content change into the overlay. + await act( async () => { + registry + .dispatch( blockEditorStore ) + .updateBlockAttributes( clientId, { + content: 'Edited', + metadata: { noteId: 11 }, + } ); + } ); + await flushSubscribers(); + + const liveAttributes = registry + .select( blockEditorStore ) + .getBlockAttributes( clientId ); + + expect( liveAttributes?.metadata?.noteId ).toBe( 11 ); + expect( liveAttributes?.content ).toBe( 'Hello' ); + expect( + getOverlay().entries[ clientId ]?.overlayAttributes?.content + ).toBe( 'Edited' ); + } ); +} ); + +describe( 'stripSystemMetadata', () => { + it( 'returns the payload unchanged when there is no metadata', () => { + const payload = { content: 'Hi' }; + expect( stripSystemMetadata( payload ) ).toBe( payload ); + } ); + + it( 'returns the payload unchanged when metadata has no system keys', () => { + const payload = { metadata: { name: 'Section' } }; + expect( stripSystemMetadata( payload ) ).toBe( payload ); + } ); + + it( 'drops noteId from metadata while keeping other keys', () => { + const payload = { metadata: { noteId: 5, name: 'Section' } }; + expect( stripSystemMetadata( payload ) ).toEqual( { + metadata: { name: 'Section' }, + } ); + } ); + + it( 'drops the metadata key entirely when only system keys remain', () => { + const payload = { content: 'Hi', metadata: { noteId: 5 } }; + expect( stripSystemMetadata( payload ) ).toEqual( { + content: 'Hi', + } ); + } ); + + it( 'preserves other top-level keys', () => { + const payload = { + content: 'Hi', + level: 3, + metadata: { noteId: 5, name: 'Heading' }, + }; + expect( stripSystemMetadata( payload ) ).toEqual( { + content: 'Hi', + level: 3, + metadata: { name: 'Heading' }, + } ); + } ); +} ); diff --git a/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js b/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js new file mode 100644 index 00000000000000..70e9683f36ea86 --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js @@ -0,0 +1,272 @@ +/** + * External dependencies + */ +import { render, screen, act, fireEvent } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { createRegistry, RegistryProvider } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import withSuggestionOverlay, { + mergeOverlayAttributes, +} from '../with-suggestion-overlay'; +import { + SuggestionOverlayProvider, + useSuggestionOverlay, +} from '../overlay-context'; +import { store as editorStore } from '../../../store'; + +function renderWithProviders( ui, { intent = 'edit' } = {} ) { + const registry = createRegistry(); + registry.register( preferencesStore ); + registry.register( editorStore ); + registry.dispatch( preferencesStore ).set( 'core', 'editorIntent', intent ); + + const wrapper = ( { children } ) => ( + + { children } + + ); + + return { + registry, + ...render( ui, { wrapper } ), + }; +} + +// Minimal block component that exposes its received attributes and +// calls setAttributes when its button is clicked. +function FakeBlock( { attributes, setAttributes } ) { + return ( + <> +
{ attributes?.content ?? '' }
+ + + ); +} + +const Wrapped = withSuggestionOverlay( FakeBlock ); + +describe( 'withSuggestionOverlay', () => { + it( 'passes through unchanged in Edit intent', () => { + const setAttributes = jest.fn(); + renderWithProviders( + + ); + + expect( screen.getByTestId( 'content' ) ).toHaveTextContent( 'Hello' ); + + fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) ); + + expect( setAttributes ).toHaveBeenCalledWith( { + content: 'proposed', + } ); + } ); + + it( 'diverts setAttributes into the overlay in Suggest intent', () => { + const setAttributes = jest.fn(); + renderWithProviders( + , + { intent: 'suggest' } + ); + + fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) ); + + // Real setAttributes is never called; block renders merged value. + expect( setAttributes ).not.toHaveBeenCalled(); + expect( screen.getByTestId( 'content' ) ).toHaveTextContent( + 'proposed' + ); + } ); + + it( 'merges overlay on top of real attributes for rendering', () => { + const setAttributes = jest.fn(); + const { rerender } = renderWithProviders( + , + { intent: 'suggest' } + ); + + fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) ); + expect( screen.getByTestId( 'content' ) ).toHaveTextContent( + 'proposed' + ); + + // Real attributes update (e.g., from RTC sync). Overlay wins on + // overlapping keys; non-overlapping keys reflect the new real value. + rerender( + + ); + expect( screen.getByTestId( 'content' ) ).toHaveTextContent( + 'proposed' + ); + } ); + + it( 'passes through in View intent — no overlay, no diversion', () => { + const setAttributes = jest.fn(); + renderWithProviders( + , + { intent: 'view' } + ); + + expect( screen.getByTestId( 'content' ) ).toHaveTextContent( + 'Untouched' + ); + + fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) ); + + // In view intent the HOC is a pass-through, so the real + // setAttributes is invoked and the overlay is never used. + expect( setAttributes ).toHaveBeenCalledWith( { + content: 'proposed', + } ); + } ); + + it( 're-captures baseline when overlay is cleared then re-edited', () => { + // Regression: after Submit/Discard clears the overlay entry, a + // later edit must create a new baseline + overlay rather than + // silently no-oping. + let clearOverlayHandle; + function Harness() { + const { clearOverlay } = useSuggestionOverlay(); + clearOverlayHandle = clearOverlay; + return null; + } + + const setAttributes = jest.fn(); + renderWithProviders( + <> + + + , + { intent: 'suggest' } + ); + + // First edit — creates overlay. + fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) ); + expect( screen.getByTestId( 'content' ) ).toHaveTextContent( + 'proposed' + ); + + // Simulate Submit/Discard clearing the overlay. + act( () => { + clearOverlayHandle( 'a' ); + } ); + expect( screen.getByTestId( 'content' ) ).toHaveTextContent( 'Hello' ); + + // Second edit — must capture a new baseline and record the + // overlay, not silently no-op. + fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) ); + expect( screen.getByTestId( 'content' ) ).toHaveTextContent( + 'proposed' + ); + // The real setAttributes is still never invoked in suggest mode. + expect( setAttributes ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'mergeOverlayAttributes', () => { + it( 'returns base unchanged when there is no overlay', () => { + const base = { content: 'Hello', level: 2 }; + expect( mergeOverlayAttributes( base, null ) ).toBe( base ); + expect( mergeOverlayAttributes( base, undefined ) ).toBe( base ); + } ); + + it( 'replaces primitive overlay values wholesale', () => { + expect( + mergeOverlayAttributes( + { content: 'Hello', level: 2 }, + { level: 3 } + ) + ).toEqual( { content: 'Hello', level: 3 } ); + } ); + + it( 'one-level merges the style attribute so untouched fields survive', () => { + expect( + mergeOverlayAttributes( + { + style: { + typography: { fontSize: '16px' }, + color: 'red', + }, + }, + { style: { color: 'blue' } } + ) + ).toEqual( { + style: { + typography: { fontSize: '16px' }, + color: 'blue', + }, + } ); + } ); + + it( 'one-level merges metadata so e.g. noteId survives a name change', () => { + expect( + mergeOverlayAttributes( + { metadata: { name: 'Block A', noteId: 42 } }, + { metadata: { name: 'Block B' } } + ) + ).toEqual( { + metadata: { name: 'Block B', noteId: 42 }, + } ); + } ); + + it( 'replaces array-valued attributes wholesale (no merge)', () => { + expect( + mergeOverlayAttributes( + { classes: [ 'a', 'b' ] }, + { classes: [ 'c' ] } + ) + ).toEqual( { classes: [ 'c' ] } ); + } ); + + it( 'replaces non-deep-merge object attributes wholesale', () => { + // `metadata` and `style` are deep-merged; everything else is + // replaced even if it happens to be an object. + expect( + mergeOverlayAttributes( + { custom: { nested: 'old' } }, + { custom: { other: 'new' } } + ) + ).toEqual( { custom: { other: 'new' } } ); + } ); +} ); diff --git a/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js b/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js new file mode 100644 index 00000000000000..a1a217a62f43d8 --- /dev/null +++ b/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js @@ -0,0 +1,153 @@ +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { useCallback, useMemo, useRef } from '@wordpress/element'; +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { useSuggestionOverlay } from './overlay-context'; +import { EDITOR_STORE_NAME, SUGGEST_INTENT } from './constants'; + +/** + * Attribute keys whose values are known to be object-valued and therefore + * need a one-level-deep merge so the overlay preserves untouched fields. + * Other attributes are replaced wholesale (which matches `setAttributes` + * semantics for primitive and array values). + */ +const DEEP_MERGE_KEYS = new Set( [ 'style', 'metadata' ] ); + +function mergeOverlayAttributes( base, overlay ) { + if ( ! overlay ) { + return base; + } + const merged = { ...base }; + for ( const [ key, value ] of Object.entries( overlay ) ) { + if ( + DEEP_MERGE_KEYS.has( key ) && + value && + typeof value === 'object' && + ! Array.isArray( value ) && + merged[ key ] && + typeof merged[ key ] === 'object' && + ! Array.isArray( merged[ key ] ) + ) { + merged[ key ] = { ...merged[ key ], ...value }; + } else { + merged[ key ] = value; + } + } + return merged; +} + +/** + * Inner renderer that owns the suggestion overlay hooks. Only mounted when + * the editor is in `suggest` intent, so the overlay's context lookup, + * refs, and memoized merge don't run on every `BlockEdit` render for every + * block across the entire editor when suggestions are inactive. This split + * matters for large documents — in Edit/View intent the outer wrapper + * executes a single `useSelect` and renders the original `BlockEdit` + * untouched. + * + * @param {Object} args Arguments. + * @param {import('react').ComponentType} args.BlockEdit Wrapped edit component. + * @param {Object} args.props Props to forward to `BlockEdit`. + */ +function SuggestingBlockEdit( { BlockEdit, props } ) { + const { clientId, name, attributes } = props; + const { entries, captureBaseline, setOverlayAttributes } = + useSuggestionOverlay(); + + // Track the latest attributes via a ref so the wrapped `setAttributes` + // callback remains stable. Blocks sometimes invoke `setAttributes` from + // effects keyed on this reference. + const attributesRef = useRef( attributes ); + attributesRef.current = attributes; + + const overlayAttributes = entries[ clientId ]?.overlayAttributes ?? null; + + // Does an overlay entry currently exist for this block? This is the + // source of truth; `captureBaseline` only creates an entry when there + // isn't one, so we can skip the dispatch when we already know there is. + // Relying on a local ref was fragile — it didn't reset after the entry + // was cleared (orphan prune, intent-switch). + const entryExists = !! entries[ clientId ]; + + const wrappedSetAttributes = useCallback( + ( nextAttributes ) => { + if ( ! entryExists ) { + captureBaseline( clientId, name, attributesRef.current ); + } + setOverlayAttributes( clientId, nextAttributes ); + }, + [ clientId, name, captureBaseline, setOverlayAttributes, entryExists ] + ); + + const mergedAttributes = useMemo( + () => mergeOverlayAttributes( attributes, overlayAttributes ), + [ attributes, overlayAttributes ] + ); + + return ( + + ); +} + +/** + * HOC that diverts block edits to the suggestion overlay when the editor is + * in the `suggest` intent. The block's real attributes are never mutated; + * overlay attributes are merged into the `attributes` prop for rendering so + * the user sees their in-progress change, but the block-editor store stays + * at the baseline until the suggestion is committed. + * + * In any other intent the HOC is a pass-through and adds only a single + * `useSelect` call per block. + */ +const withSuggestionOverlay = createHigherOrderComponent( + ( BlockEdit ) => + function BlockEditWithSuggestionOverlay( props ) { + const isSuggestMode = useSelect( + ( select ) => + select( EDITOR_STORE_NAME ).getEditorIntent() === + SUGGEST_INTENT, + [] + ); + + if ( ! isSuggestMode ) { + return ; + } + + return ( + + ); + }, + 'withSuggestionOverlay' +); + +let filterRegistered = false; + +/** + * Register the overlay filter. Idempotent — safe to call multiple times + * (hot reload, dynamic imports). + */ +export function registerSuggestionOverlayFilter() { + if ( filterRegistered ) { + return; + } + filterRegistered = true; + addFilter( + 'editor.BlockEdit', + 'core/editor/suggestion-mode-overlay', + withSuggestionOverlay + ); +} + +export { mergeOverlayAttributes }; +export default withSuggestionOverlay; diff --git a/test/e2e/specs/editor/various/suggestion-mode.spec.js b/test/e2e/specs/editor/various/suggestion-mode.spec.js new file mode 100644 index 00000000000000..2b7fa266d97f8d --- /dev/null +++ b/test/e2e/specs/editor/various/suggestion-mode.spec.js @@ -0,0 +1,78 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +async function switchIntent( page, intentLabel ) { + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + const menuItem = page.getByRole( 'menuitemradio', { + name: new RegExp( `^${ intentLabel }` ), + } ); + await menuItem.waitFor( { state: 'visible', timeout: 10000 } ); + await menuItem.click(); +} + +test.describe( 'Suggestion mode', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'captures edits as an overlay without mutating the block', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Original content' }, + } ); + + await switchIntent( page, 'Suggest' ); + + const paragraph = editor.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .first(); + await paragraph.click(); + await page.keyboard.press( 'End' ); + await page.keyboard.type( ' plus suggested' ); + + // The rendered block reflects the overlayed suggestion. + await expect( paragraph ).toContainText( + 'Original content plus suggested' + ); + + // The serialized post content stays at the baseline — the overlay + // never touched the block-editor store. + const serialized = await editor.getEditedPostContent(); + expect( serialized ).toContain( 'Original content' ); + expect( serialized ).not.toContain( 'plus suggested' ); + } ); + + test( 'restores baseline when switching back to Edit intent', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Keep as is' }, + } ); + + await switchIntent( page, 'Suggest' ); + + const paragraph = editor.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .first(); + await paragraph.click(); + await page.keyboard.press( 'End' ); + await page.keyboard.type( '!' ); + await expect( paragraph ).toContainText( 'Keep as is!' ); + + // Switching out of Suggest intent un-merges the overlay; the block + // renders the real attributes again. + await switchIntent( page, 'Edit' ); + await expect( paragraph ).toContainText( 'Keep as is' ); + await expect( paragraph ).not.toContainText( 'Keep as is!' ); + } ); +} ); diff --git a/tools/eslint/suppressions.json b/tools/eslint/suppressions.json index f2f71cee64561f..47c01ccfeba492 100644 --- a/tools/eslint/suppressions.json +++ b/tools/eslint/suppressions.json @@ -1108,6 +1108,21 @@ "count": 2 } }, + "packages/editor/src/components/suggestion-mode/store-interceptor.js": { + "react-hooks/refs": { + "count": 3 + } + }, + "packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js": { + "react-hooks/globals": { + "count": 1 + } + }, + "packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js": { + "react-hooks/refs": { + "count": 1 + } + }, "packages/editor/src/components/sync-connection-error-modal/index.tsx": { "@wordpress/use-recommended-components": { "count": 2