diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md index 3191956f8d99b0..b9a4bc916ce64e 100644 --- a/docs/reference-guides/data/data-core-editor.md +++ b/docs/reference-guides/data/data-core-editor.md @@ -381,6 +381,22 @@ _Returns_ - `Array`: Block list. +### getEditorIntent + +Returns the current editor intent. The intent represents the user's editing purpose — directly editing content (`edit`), suggesting changes that the author can apply or reject (`suggest`), or viewing the post in a read-only mode (`view`). + +The intent is orthogonal to the `editorMode` preference (visual vs. code). + +Storage: the value lives in the preferences store under (`core`, `editorIntent`). The per-app default is registered in `packages/edit-post/src/index.js` and `packages/edit-site/src/index.js`. If no value is set we fall back to `EDITOR_INTENT_EDIT` so callers can rely on a non-null result. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `string`: The current editor intent. One of `edit`, `suggest`, `view`. + ### getEditorMode Returns the current editing mode. @@ -1495,6 +1511,18 @@ _Returns_ - `Object`: Action object. +### setEditorIntent + +Sets the current editor intent. + +The intent represents the user's editing purpose: directly editing content (`edit`), suggesting changes that the author can apply or reject (`suggest`), or viewing the post in a read-only mode (`view`). It is orthogonal to the `editorMode` preference (visual vs. code). + +The value persists via the preferences store so the intent survives reloads. Unknown intents are silently rejected (no dispatch, no announcement) to keep typos or stale values from corrupting the preference; valid values are listed in `EDITOR_INTENTS`. + +_Parameters_ + +- _intent_ `'edit'|'suggest'|'view'`: The editor intent to set. + ### setIsInserterOpened Returns an action object used to open/close the inserter. diff --git a/packages/edit-post/src/index.js b/packages/edit-post/src/index.js index 05a8ff8691ea6b..1339009b8e64d7 100644 --- a/packages/edit-post/src/index.js +++ b/packages/edit-post/src/index.js @@ -61,6 +61,7 @@ export function initializeEditor( dispatch( preferencesStore ).setDefaults( 'core', { allowRightClickOverrides: true, + editorIntent: 'edit', editorMode: 'visual', editorTool: 'edit', fixedToolbar: false, diff --git a/packages/edit-site/src/index.js b/packages/edit-site/src/index.js index 33fcccf4963ca8..990452ebdd8af9 100644 --- a/packages/edit-site/src/index.js +++ b/packages/edit-site/src/index.js @@ -63,6 +63,7 @@ export function initializeEditor( id, settings ) { dispatch( preferencesStore ).setDefaults( 'core', { allowRightClickOverrides: true, distractionFree: false, + editorIntent: 'edit', editorMode: 'visual', editorTool: 'edit', fixedToolbar: false, diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 311a127ea1755b..48e958264e5337 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### 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. + ### Bug Fixes - `mediaFinalize` now returns the post-finalize attachment (transformed from the REST response), so the upload-media queue can refresh the in-flight attachment URL. Required for the front-end `srcset` to render on client-side-media uploads that exceeded the big-image threshold. diff --git a/packages/editor/src/components/global-keyboard-shortcuts/index.js b/packages/editor/src/components/global-keyboard-shortcuts/index.js index a46d4b55a7bfd8..6abf850b4b7835 100644 --- a/packages/editor/src/components/global-keyboard-shortcuts/index.js +++ b/packages/editor/src/components/global-keyboard-shortcuts/index.js @@ -10,6 +10,11 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; * Internal dependencies */ import { store as editorStore } from '../../store'; +import { + EDITOR_INTENT_EDIT, + EDITOR_INTENT_SUGGEST, + EDITOR_INTENT_VIEW, +} from '../../store/constants'; /** * Handles the keyboard shortcuts for the editor. @@ -35,6 +40,7 @@ export default function EditorKeyboardShortcuts() { setIsListViewOpened, switchEditorMode, toggleDistractionFree, + setEditorIntent, } = useDispatch( editorStore ); const { isEditedPostDirty, @@ -99,6 +105,21 @@ export default function EditorKeyboardShortcuts() { } } ); + useShortcut( 'core/editor/intent-edit', ( event ) => { + event.preventDefault(); + setEditorIntent( EDITOR_INTENT_EDIT ); + } ); + + useShortcut( 'core/editor/intent-suggest', ( event ) => { + event.preventDefault(); + setEditorIntent( EDITOR_INTENT_SUGGEST ); + } ); + + useShortcut( 'core/editor/intent-view', ( event ) => { + event.preventDefault(); + setEditorIntent( EDITOR_INTENT_VIEW ); + } ); + useShortcut( 'core/editor/toggle-sidebar', ( event ) => { // This shortcut has no known clashes, but use preventDefault to prevent any // obscure shortcuts from triggering. diff --git a/packages/editor/src/components/global-keyboard-shortcuts/register-shortcuts.js b/packages/editor/src/components/global-keyboard-shortcuts/register-shortcuts.js index 48b19405196ca4..87c1b8b5f373d4 100644 --- a/packages/editor/src/components/global-keyboard-shortcuts/register-shortcuts.js +++ b/packages/editor/src/components/global-keyboard-shortcuts/register-shortcuts.js @@ -119,6 +119,36 @@ function EditorKeyboardShortcutsRegister() { }, } ); + registerShortcut( { + name: 'core/editor/intent-edit', + category: 'global', + description: __( 'Switch to Edit mode.' ), + keyCombination: { + modifier: 'secondary', + character: 'z', + }, + } ); + + registerShortcut( { + name: 'core/editor/intent-suggest', + category: 'global', + description: __( 'Switch to Suggest mode.' ), + keyCombination: { + modifier: 'secondary', + character: 'x', + }, + } ); + + registerShortcut( { + name: 'core/editor/intent-view', + category: 'global', + description: __( 'Switch to View mode.' ), + keyCombination: { + modifier: 'secondary', + character: 'c', + }, + } ); + registerShortcut( { name: 'core/editor/next-region', category: 'global', diff --git a/packages/editor/src/components/intent-switcher/index.js b/packages/editor/src/components/intent-switcher/index.js new file mode 100644 index 00000000000000..e04331009ade55 --- /dev/null +++ b/packages/editor/src/components/intent-switcher/index.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { MenuItemsChoice, MenuGroup } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { displayShortcut } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; +import { + EDITOR_INTENT_EDIT, + EDITOR_INTENT_SUGGEST, + EDITOR_INTENT_VIEW, +} from '../../store/constants'; +import PostTypeSupportCheck from '../post-type-support-check'; + +/** + * Available editor intent options surfaced in the more-menu mode picker. + * + * Each option is mirrored across three files; keep them in sync when adding + * or renaming an intent: + * - This file: UI label, description, and shortcut hint. + * - `../global-keyboard-shortcuts/register-shortcuts.js`: registers the + * keyboard binding with `@wordpress/keyboard-shortcuts`. + * - `../global-keyboard-shortcuts/index.js`: wires `useShortcut` so the + * binding dispatches `setEditorIntent`. + * + * The `value` field must be one of the `EDITOR_INTENT_*` constants — the + * `setEditorIntent` action validates against `EDITOR_INTENTS` and silently + * ignores unknown values. + * + * @type {Array<{value: string, label: string, info: string, shortcut: string}>} + */ +const INTENTS = [ + { + value: EDITOR_INTENT_EDIT, + label: __( 'Edit' ), + info: __( 'Edit content directly.' ), + shortcut: displayShortcut.secondary( 'z' ), + }, + { + value: EDITOR_INTENT_SUGGEST, + label: __( 'Suggest' ), + info: __( 'Propose changes the author can apply or reject.' ), + shortcut: displayShortcut.secondary( 'x' ), + }, + { + value: EDITOR_INTENT_VIEW, + label: __( 'View' ), + info: __( 'Read-only preview of the content.' ), + shortcut: displayShortcut.secondary( 'c' ), + }, +]; + +/** + * Editor intent switcher. Lets the user pick between direct editing, + * suggesting changes, or viewing in read-only. Only rendered for post + * types that declare the `editor.notes` support. + */ +function IntentSwitcher() { + const intent = useSelect( + ( select ) => select( editorStore ).getEditorIntent(), + [] + ); + const { setEditorIntent } = useDispatch( editorStore ); + + return ( + + + + + + ); +} + +export default IntentSwitcher; diff --git a/packages/editor/src/components/more-menu/index.js b/packages/editor/src/components/more-menu/index.js index 3a4cf8a66d5926..116153ff8348b7 100644 --- a/packages/editor/src/components/more-menu/index.js +++ b/packages/editor/src/components/more-menu/index.js @@ -17,6 +17,7 @@ import { VisuallyHidden } from '@wordpress/ui'; * Internal dependencies */ import CopyContentMenuItem from './copy-content-menu-item'; +import IntentSwitcher from '../intent-switcher'; import ModeSwitcher from '../mode-switcher'; import ToolsMoreMenuGroup from './tools-more-menu-group'; import ViewMoreMenuGroup from './view-more-menu-group'; @@ -106,6 +107,7 @@ export default function MoreMenu( { disabled = false } ) { /> + + ( { registry } ) => { + // Reject unknown intents instead of throwing so a typo from a + // bookmarklet, browser extension, or third-party plugin can't poison + // the persisted preference. + if ( ! EDITOR_INTENTS.includes( intent ) ) { + return; + } + + registry + .dispatch( preferencesStore ) + .set( 'core', 'editorIntent', intent ); + + if ( intent === EDITOR_INTENT_EDIT ) { + speak( __( 'Edit mode selected' ), 'assertive' ); + } else if ( intent === EDITOR_INTENT_SUGGEST ) { + speak( __( 'Suggest mode selected' ), 'assertive' ); + } else if ( intent === EDITOR_INTENT_VIEW ) { + speak( __( 'View mode selected' ), 'assertive' ); + } + }; + /** * Returns an action object used in signalling that the user opened the publish * sidebar. diff --git a/packages/editor/src/store/constants.ts b/packages/editor/src/store/constants.ts index 9a4b35dcdc9614..004a82f5ba51c1 100644 --- a/packages/editor/src/store/constants.ts +++ b/packages/editor/src/store/constants.ts @@ -37,3 +37,38 @@ export const DESIGN_POST_TYPES = [ PATTERN_POST_TYPE, NAVIGATION_POST_TYPE, ]; + +/** + * Editor intent values. The intent represents the user's current editing + * purpose (edit the post directly, suggest changes, or view in read-only). + * + * Orthogonal to the `editorMode` preference (visual vs. code): a user can + * be in `suggest` intent in either visual or code mode. + * + * Storage and defaults: + * - Persisted via `@wordpress/preferences` under (`core`, `editorIntent`), + * so the intent survives reloads. + * - The per-app default is registered in `packages/edit-post/src/index.js` + * and `packages/edit-site/src/index.js` (both default to `'edit'`). + * - `getEditorIntent` falls back to `EDITOR_INTENT_EDIT` when no value + * is set, so consumers can rely on a non-null result. + * + * Suggest Mode context: + * Phase 1 of the Suggest Mode feature only wires the intent state and the + * UI surface (menu + keyboard shortcuts). Subsequent phases use the + * `suggest` intent to capture edits as in-memory overlays, render them as + * suggestions, and let other users apply or reject them. Adding a new + * intent here also requires updates to: + * - packages/editor/src/components/intent-switcher/index.js (UI choices) + * - packages/editor/src/components/global-keyboard-shortcuts/* (shortcut + * registration and dispatch) + */ +export const EDITOR_INTENT_EDIT = 'edit'; +export const EDITOR_INTENT_SUGGEST = 'suggest'; +export const EDITOR_INTENT_VIEW = 'view'; +export const EDITOR_INTENTS = [ + EDITOR_INTENT_EDIT, + EDITOR_INTENT_SUGGEST, + EDITOR_INTENT_VIEW, +] as const; +export type EditorIntent = ( typeof EDITOR_INTENTS )[ number ]; diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 926aa7af02ac7a..3857a8cf07eaac 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -25,6 +25,7 @@ import { PERMALINK_POSTNAME_REGEX, ONE_MINUTE_IN_MS, AUTOSAVE_PROPERTIES, + EDITOR_INTENT_EDIT, } from './constants'; import { getPostRawValue } from './reducer'; import { getTemplatePartIcon } from '../utils/get-template-part-icon'; @@ -1392,6 +1393,30 @@ export const getEditorMode = createRegistrySelector( select( preferencesStore ).get( 'core', 'editorMode' ) ?? 'visual' ); +/** + * Returns the current editor intent. The intent represents the user's + * editing purpose — directly editing content (`edit`), suggesting changes + * that the author can apply or reject (`suggest`), or viewing the post in + * a read-only mode (`view`). + * + * The intent is orthogonal to the `editorMode` preference (visual vs. code). + * + * Storage: the value lives in the preferences store under + * (`core`, `editorIntent`). The per-app default is registered in + * `packages/edit-post/src/index.js` and `packages/edit-site/src/index.js`. + * If no value is set we fall back to `EDITOR_INTENT_EDIT` so callers can + * rely on a non-null result. + * + * @param {Object} state Global application state. + * + * @return {string} The current editor intent. One of `edit`, `suggest`, `view`. + */ +export const getEditorIntent = createRegistrySelector( + ( select ) => () => + select( preferencesStore ).get( 'core', 'editorIntent' ) ?? + EDITOR_INTENT_EDIT +); + /* * Backward compatibility */ diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 206c60a159d04f..d83ca6ee763527 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -547,6 +547,54 @@ describe( 'Editor actions', () => { } ); } ); + describe( 'setEditorIntent', () => { + let registry; + + beforeEach( () => { + registry = createRegistryWithStores(); + } ); + + it( 'defaults to edit', () => { + expect( registry.select( editorStore ).getEditorIntent() ).toEqual( + 'edit' + ); + } ); + + it( 'switches between edit, suggest, and view', () => { + registry.dispatch( editorStore ).setEditorIntent( 'suggest' ); + expect( registry.select( editorStore ).getEditorIntent() ).toEqual( + 'suggest' + ); + + registry.dispatch( editorStore ).setEditorIntent( 'view' ); + expect( registry.select( editorStore ).getEditorIntent() ).toEqual( + 'view' + ); + + registry.dispatch( editorStore ).setEditorIntent( 'edit' ); + expect( registry.select( editorStore ).getEditorIntent() ).toEqual( + 'edit' + ); + } ); + + it( 'ignores unknown intents', () => { + registry.dispatch( editorStore ).setEditorIntent( 'suggest' ); + registry.dispatch( editorStore ).setEditorIntent( 'bogus' ); + expect( registry.select( editorStore ).getEditorIntent() ).toEqual( + 'suggest' + ); + } ); + + it( 'persists to the preferences store', () => { + registry.dispatch( editorStore ).setEditorIntent( 'view' ); + expect( + registry + .select( preferencesStore ) + .get( 'core', 'editorIntent' ) + ).toEqual( 'view' ); + } ); + } ); + describe( 'toggleDistractionFree', () => { it( 'should properly update settings to prevent layout corruption when enabling distraction free mode', () => { const registry = createRegistryWithStores(); diff --git a/test/e2e/specs/editor/various/editor-intent-switcher.spec.js b/test/e2e/specs/editor/various/editor-intent-switcher.spec.js new file mode 100644 index 00000000000000..b4171b3577046b --- /dev/null +++ b/test/e2e/specs/editor/various/editor-intent-switcher.spec.js @@ -0,0 +1,98 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +async function openIntentSwitcher( page ) { + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Options"i]' + ); +} + +test.describe( 'Editor intent switcher', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'defaults to Edit intent and persists across reload', async ( { + page, + editor, + } ) => { + await openIntentSwitcher( page ); + + // Use full accessible names (label + info) to disambiguate from the + // sibling Visual/Code editor menuitemradios which would otherwise + // match 'Edit' via Playwright's substring search. + const editChoice = page.getByRole( 'menuitemradio', { + name: /^Edit\s+Edit content directly/, + } ); + const suggestChoice = page.getByRole( 'menuitemradio', { + name: /^Suggest/, + } ); + const viewChoice = page.getByRole( 'menuitemradio', { + name: /^View\s+Read-only/, + } ); + + await expect( editChoice ).toBeVisible(); + await expect( suggestChoice ).toBeVisible(); + await expect( viewChoice ).toBeVisible(); + await expect( editChoice ).toHaveAttribute( 'aria-checked', 'true' ); + + // Select Suggest and confirm selection persists across reload. + await suggestChoice.click(); + await page.reload(); + await editor.canvas.locator( 'body' ).waitFor(); + await openIntentSwitcher( page ); + await expect( + page.getByRole( 'menuitemradio', { name: /^Suggest/ } ) + ).toHaveAttribute( 'aria-checked', 'true' ); + } ); + + test( 'View intent makes blocks read-only', async ( { editor, page } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Initial content' }, + } ); + + await openIntentSwitcher( page ); + await page + .getByRole( 'menuitemradio', { name: /^View\s+Read-only/ } ) + .click(); + + // In preview mode, block content is not editable — the paragraph + // should render but clicking and typing should not change it. + const paragraph = editor.canvas.getByText( 'Initial content' ); + await expect( paragraph ).toBeVisible(); + await expect( paragraph ).not.toHaveAttribute( + 'contenteditable', + 'true' + ); + } ); + + test( 'keyboard shortcut cycles between intents', async ( { page } ) => { + // Default is Edit. + await page.keyboard.press( 'Control+Alt+Shift+X' ); + await openIntentSwitcher( page ); + await expect( + page.getByRole( 'menuitemradio', { name: /^Suggest/ } ) + ).toHaveAttribute( 'aria-checked', 'true' ); + + // Close menu and switch to View via shortcut. + await page.keyboard.press( 'Escape' ); + await page.keyboard.press( 'Control+Alt+Shift+C' ); + await openIntentSwitcher( page ); + await expect( + page.getByRole( 'menuitemradio', { name: /^View\s+Read-only/ } ) + ).toHaveAttribute( 'aria-checked', 'true' ); + + // Back to Edit. + await page.keyboard.press( 'Escape' ); + await page.keyboard.press( 'Control+Alt+Shift+Z' ); + await openIntentSwitcher( page ); + await expect( + page.getByRole( 'menuitemradio', { + name: /^Edit\s+Edit content directly/, + } ) + ).toHaveAttribute( 'aria-checked', 'true' ); + } ); +} );