Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
43 changes: 43 additions & 0 deletions lib/compat/wordpress-6.9/block-comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,49 @@ function gutenberg_register_block_comment_metadata() {
},
)
);

// Suggestion payload attached to a note. A note comment with this meta set
// is a suggested edit: the value is a JSON-encoded payload describing the
// block, baseline revision, and proposed operations. See
// packages/editor/src/components/suggestion-mode/provider.js for the shape.
register_meta(
'comment',
'_wp_suggestion',
array(
'type' => 'string',
'description' => __( 'Suggested edit payload (JSON).', 'gutenberg' ),
'single' => true,
'show_in_rest' => array(
'schema' => array(
'type' => 'string',
),
),
'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 Phase 3's apply/reject actions.
Comment thread
adamsilverstein marked this conversation as resolved.
Outdated
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' );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ public function create_item( $request ) {
if ( isset( $request['meta']['_wp_note_status'] ) ) {
$prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status'];
}
if ( isset( $request['meta']['_wp_suggestion'] ) ) {
$prepared_comment['meta']['_wp_suggestion'] = $request['meta']['_wp_suggestion'];
}

if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) {
return new WP_Error(
Expand Down Expand Up @@ -605,6 +608,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().
Expand Down
1 change: 1 addition & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

## 14.44.0 (2026-04-15)

Expand Down
53 changes: 33 additions & 20 deletions packages/editor/src/components/provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ import EditorKeyboardShortcuts from '../global-keyboard-shortcuts';
import PatternRenameModal from '../pattern-rename-modal';
import PatternDuplicateModal from '../pattern-duplicate-modal';
import TemplatePartMenuItems from '../template-part-menu-items';
import {
SuggestionOverlayProvider,
SuggestionCommitBar,
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 );
Expand Down Expand Up @@ -447,26 +457,29 @@ export const ExperimentalEditorProvider = withRegistryProvider(
settings={ blockEditorSettings }
useSubRegistry={ false }
>
{ children }
{ ! settings.isPreviewMode && (
<>
<PatternsMenuItems />
<TemplatePartMenuItems />
{ mode === 'template-locked' && (
<DisableNonPageContentBlocks />
) }
{ type === 'wp_navigation' && (
<NavigationBlockEditingMode />
) }
<EditorKeyboardShortcuts />
<KeyboardShortcutHelpModal />
<BlockRemovalWarnings />
<StartPageOptions />
<StartTemplateOptions />
<PatternRenameModal />
<PatternDuplicateModal />
</>
) }
<SuggestionOverlayProvider>
{ children }
{ ! settings.isPreviewMode && (
<>
<PatternsMenuItems />
<TemplatePartMenuItems />
{ mode === 'template-locked' && (
<DisableNonPageContentBlocks />
) }
{ type === 'wp_navigation' && (
<NavigationBlockEditingMode />
) }
<EditorKeyboardShortcuts />
<KeyboardShortcutHelpModal />
<BlockRemovalWarnings />
<StartPageOptions />
<StartTemplateOptions />
<PatternRenameModal />
<PatternDuplicateModal />
<SuggestionCommitBar />
</>
) }
</SuggestionOverlayProvider>
</BlockEditorProviderComponent>
</BlockContextProvider>
</EntityProvider>
Expand Down
109 changes: 109 additions & 0 deletions packages/editor/src/components/suggestion-mode/commit-bar.js
Original file line number Diff line number Diff line change
@@ -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 (
<BlockControls group="other">
<ToolbarGroup>
<ToolbarButton
variant="primary"
onClick={ onSubmit }
disabled={ isSubmitting }
>
{ isSubmitting
? __( 'Submitting…' )
: __( 'Submit suggestion' ) }
</ToolbarButton>
<ToolbarButton onClick={ onDiscard } disabled={ isSubmitting }>
{ __( 'Discard' ) }
</ToolbarButton>
</ToolbarGroup>
</BlockControls>
);
}
7 changes: 7 additions & 0 deletions packages/editor/src/components/suggestion-mode/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* 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';
15 changes: 15 additions & 0 deletions packages/editor/src/components/suggestion-mode/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export {
SuggestionOverlayProvider,
useSuggestionOverlay,
overlayReducer,
} from './overlay-context';
export {
default as withSuggestionOverlay,
registerSuggestionOverlayFilter,
} from './with-suggestion-overlay';
export { default as SuggestionCommitBar } from './commit-bar';
export {
useSuggestionsProvider,
operationsFromOverlay,
SCHEMA_VERSION,
} from './provider';
Loading
Loading