From 2920973694400acec58c7aee0a7dc058c0c9d41d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 30 Jun 2026 10:53:14 -0700 Subject: [PATCH 01/27] Suggest mode: core subsystem (intent modes, overlay capture, structural + attribute suggestions) Fresh-stack migration of the Suggest mode foundation onto current trunk, re-sliced from the suggest-mode feature branches into a single reviewable base. Includes the Edit/Suggest/View intent switcher, the in-memory overlay capture + store interceptor, attribute and structural (block remove/insert/ move) suggestions with the v2 declarative payload schema, the suggestion diff/summary, auto-save, the collaboration-sidebar Apply/Reject controls, and the REST/PHP backend (note-suggestion controller + render strip). The inline (in-text marker) layer is split into a separate stacked branch. This is a behaviour-preserving migration; planned refactors (overlay-inline retirement, identity-token interceptor, structural op-descriptor table) follow as separate PRs. Replaces the prior 16-deep stack (see #73411). --- docs/explanations/architecture/suggestions.md | 242 ++++ .../reference-guides/data/data-core-editor.md | 30 + .../2026-06-16-suggest-mode-inline-markers.md | 291 +++++ .../wordpress-7.1/block-suggestions.php | 112 ++ ...-gutenberg-rest-comment-controller-7-1.php | 238 ++++ lib/load.php | 2 + package-lock.json | 1 + .../block-list/content-suggestion.scss | 150 +++ packages/block-editor/src/content.scss | 1 + packages/edit-post/src/index.js | 1 + packages/edit-site/src/index.js | 1 + packages/editor/CHANGELOG.md | 6 + packages/editor/package.json | 1 + .../src/components/collab-sidebar/format.js | 8 +- .../src/components/collab-sidebar/note.js | 28 +- .../collab-sidebar/suggestion-actions.js | 256 ++++ .../components/collab-sidebar/test/hooks.js | 157 +++ .../global-keyboard-shortcuts/index.js | 21 + .../register-shortcuts.js | 30 + .../src/components/intent-switcher/index.js | 83 ++ .../editor/src/components/more-menu/index.js | 2 + .../editor/src/components/provider/index.js | 57 +- .../provider/use-block-editor-settings.js | 13 +- .../components/suggestion-mode/auto-save.js | 268 ++++ .../components/suggestion-mode/constants.js | 13 + .../src/components/suggestion-mode/index.js | 28 + .../suggestion-mode/move-ghost-index.js | 98 ++ .../suggestion-mode/overlay-context.js | 491 ++++++++ .../components/suggestion-mode/provider.js | 921 ++++++++++++++ .../suggestion-mode/store-interceptor.js | 1083 +++++++++++++++++ .../src/components/suggestion-mode/style.scss | 63 + .../suggestion-mode/suggestion-diff.js | 453 +++++++ .../suggestion-mode/suggestion-move-ghost.js | 115 ++ .../suggestion-mode/suggestion-summary.js | 359 ++++++ .../suggestion-mode/test/auto-save.js | 499 ++++++++ .../suggestion-mode/test/move-ghost-index.js | 258 ++++ .../suggestion-mode/test/overlay-context.js | 270 ++++ .../suggestion-mode/test/provider.js | 471 +++++++ .../suggestion-mode/test/store-interceptor.js | 1050 ++++++++++++++++ .../test/suggestion-diff-move.js | 78 ++ .../suggestion-mode/test/suggestion-diff.js | 126 ++ .../test/suggestion-move-ghost.js | 100 ++ .../test/suggestion-summary.js | 267 ++++ .../test/with-suggestion-overlay.js | 588 +++++++++ .../with-suggestion-overlay.js | 323 +++++ packages/editor/src/store/actions.js | 65 + packages/editor/src/store/constants.ts | 35 + packages/editor/src/store/reducer.js | 21 + packages/editor/src/store/selectors.js | 23 + packages/editor/src/store/test/actions.js | 48 + packages/editor/src/style.scss | 1 + packages/editor/tsconfig.json | 1 + ...est-comments-controller-gutenberg-test.php | 428 ++++++- .../various/editor-intent-switcher.spec.js | 101 ++ .../editor/various/suggestion-mode.spec.js | 313 +++++ tools/eslint/suppressions.json | 40 + 56 files changed, 10695 insertions(+), 34 deletions(-) create mode 100644 docs/explanations/architecture/suggestions.md create mode 100644 docs/superpowers/plans/2026-06-16-suggest-mode-inline-markers.md create mode 100644 lib/compat/wordpress-7.1/block-suggestions.php create mode 100644 lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php create mode 100644 packages/block-editor/src/components/block-list/content-suggestion.scss create mode 100644 packages/editor/src/components/collab-sidebar/suggestion-actions.js create mode 100644 packages/editor/src/components/collab-sidebar/test/hooks.js create mode 100644 packages/editor/src/components/intent-switcher/index.js create mode 100644 packages/editor/src/components/suggestion-mode/auto-save.js create mode 100644 packages/editor/src/components/suggestion-mode/constants.js create mode 100644 packages/editor/src/components/suggestion-mode/index.js create mode 100644 packages/editor/src/components/suggestion-mode/move-ghost-index.js create mode 100644 packages/editor/src/components/suggestion-mode/overlay-context.js create mode 100644 packages/editor/src/components/suggestion-mode/provider.js create mode 100644 packages/editor/src/components/suggestion-mode/store-interceptor.js create mode 100644 packages/editor/src/components/suggestion-mode/style.scss create mode 100644 packages/editor/src/components/suggestion-mode/suggestion-diff.js create mode 100644 packages/editor/src/components/suggestion-mode/suggestion-move-ghost.js create mode 100644 packages/editor/src/components/suggestion-mode/suggestion-summary.js create mode 100644 packages/editor/src/components/suggestion-mode/test/auto-save.js create mode 100644 packages/editor/src/components/suggestion-mode/test/move-ghost-index.js create mode 100644 packages/editor/src/components/suggestion-mode/test/overlay-context.js create mode 100644 packages/editor/src/components/suggestion-mode/test/provider.js create mode 100644 packages/editor/src/components/suggestion-mode/test/store-interceptor.js create mode 100644 packages/editor/src/components/suggestion-mode/test/suggestion-diff-move.js create mode 100644 packages/editor/src/components/suggestion-mode/test/suggestion-diff.js create mode 100644 packages/editor/src/components/suggestion-mode/test/suggestion-move-ghost.js create mode 100644 packages/editor/src/components/suggestion-mode/test/suggestion-summary.js create mode 100644 packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js create mode 100644 packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js create mode 100644 test/e2e/specs/editor/various/editor-intent-switcher.spec.js create mode 100644 test/e2e/specs/editor/various/suggestion-mode.spec.js diff --git a/docs/explanations/architecture/suggestions.md b/docs/explanations/architecture/suggestions.md new file mode 100644 index 00000000000000..aa988762b12534 --- /dev/null +++ b/docs/explanations/architecture/suggestions.md @@ -0,0 +1,242 @@ +# Suggestions Architecture + +## Overview + +Suggestions extend the Notes feature (block-level comments) to support proposed content changes. A reviewer switches to **Suggest** intent and edits the content — changing a block's attributes, or inserting, removing, or moving blocks; each change is captured as a versioned suggestion payload on a note comment, auto-saved in the background after a short idle window. The post author then **Accepts** (merges the change) or **Rejects** (dismisses it) from the notes sidebar. + +The feature is designed around a swappable provider interface so the storage backend can evolve from comment-meta (today) to Yjs `AttributionManager` (future) without changing the UI or accept/reject logic. + +## End-to-end lifecycle + +```mermaid +sequenceDiagram + autonumber + participant U as Reviewer + participant B as Block + participant O as Overlay store + participant AS as AutoSave (debounced) + participant P as SuggestionsProvider + participant R as REST (/wp/v2/comments) + participant A as Post author + + U->>B: Switch to Suggest intent, edit block + B->>O: setAttributes → overlay (capture baseline on first edit) + Note right of O: Block-editor store is NEVER written + O->>AS: Overlay changed (debounce ~1.5s) + AS->>P: createSuggestion or updateSuggestion + P->>R: POST / PUT note + _wp_suggestion meta + R-->>P: Saved comment + P->>B: updateBlockAttributes(metadata.noteId) (on create) + + A->>A: Open notes sidebar + A->>P: Accept (or Reject) + alt baseRevision stale + P-->>A: Confirm dialog ("Apply anyway?") + end + P->>B: updateBlockAttributes(applyOperations(...)) + P->>R: PUT status=approved + _wp_suggestion_status +``` + +## Editor Intent + +An `editorIntent` preference (orthogonal to the visual/code `editorMode`) controls the editing purpose: + +| Intent | Behaviour | +|-----------|-----------| +| `edit` | Default — direct editing. | +| `suggest` | Edits are diverted into an in-memory overlay; the block-editor store is never mutated. | +| `view` | Read-only preview via `isPreviewMode`. | + +The intent is stored in the preferences store under `core.editorIntent` and surfaced as an **Edit / Suggest / View** menu in the editor's "Options" kebab, gated behind the `editor.notes` post-type support flag. + +## Suggestion Overlay + +When the intent is `suggest`, an `editor.BlockEdit` filter (`withSuggestionOverlay`) wraps every block's `Edit` component: + +1. **Baseline capture** — on the first `setAttributes` call, the block's current attributes are snapshotted. +2. **Diversion** — `setAttributes` writes to a React-context-backed overlay (`SuggestionOverlayProvider`) keyed by `clientId`, not the block-editor store. +3. **Merge for render** — the block receives `{ ...realAttributes, ...overlayAttributes }` so the user sees their in-progress change live. + +A companion `editor.BlockListBlock` filter tags each block with a pending change so it is discoverable without relying on the selected-block toolbar. Attribute edits get an `is-suggestion-pending` class (the bracket/outline treatment); pending structural changes get `is-suggestion-pending-remove` (strikethrough/dim), `is-suggestion-pending-insert`, or `is-suggestion-pending-move`, mapped from the block's `metadata.suggestion` marker. + +Because the store is never touched, autosave, undo/redo, and RTC sync stay at the real baseline. + +### Inline preview marks + +For text-valued (RichText) attributes, the overlay HOC also renders the change **inline** inside the block, Google-Docs style, rather than only in the sidebar. On each render (gated on `! isBlockSelected`, so the marks never fight the caret), `markContentDiff` word-diffs the baseline against the proposed value and wraps the runs: removed runs in ``, added runs in ``. The marked HTML is fed back into the block's RichText for display only. + +The persisted value is never the marked one. Incoming `setAttributes` payloads are passed through `stripSuggestionMarks` first, so the overlay always stores the *clean* proposed value — without this, the next render would diff against an already-marked value and double up the marks. The two format types (`gutenberg/suggested-deletion`, `gutenberg/suggested-addition`) are registered without an `edit` UI in `inline-formats.js` so they never appear in the block toolbar. + +When the suggester's user id is known, `getAvatarBorderColor` resolves their avatar color and it rides on each ``/`` as an inline `style="--suggestion-author-color: …"`, so two suggesters' marks are distinguishable at a glance. Omitting the color leaves the existing red/green CSS fallback, so single-suggester sessions look unchanged. + +### Auto-save + +There is no manual "Submit" step — `SuggestionAutoSave` watches the overlay and, after ~1.5 s of idle time on a given block, persists the current operations as a note comment. The overlay entry tracks the resulting `commentId` and a fingerprint of the last synced operations, so subsequent edits update the same note rather than creating new ones. If an edit is undone back to baseline the auto-saver trashes the note instead. + +### Store interceptor + +The HOC only catches edits that flow through a block's own `setAttributes` prop. Some Gutenberg paths bypass the prop chain and dispatch `updateBlockAttributes` directly to the block-editor store — most notably the block-switcher's variation picker (e.g. swapping a heading from H2 → H3). Those mutations would otherwise land in the post unchanged, defeating Suggest mode. + +`SuggestionStoreInterceptor` is a companion subscriber that closes that gap: + +1. On Suggest activation it snapshots every block's attributes. +2. It subscribes to the data registry. On every store update it diffs the live attributes against the snapshot. +3. For drift on a tracked block it routes the changed attributes into the overlay and dispatches a revert that restores the snapshot. An `isReverting` flag suppresses the recursive subscribe fire that the revert itself triggers. +4. Structural mutations (a block inserted, removed, or moved) are captured too — see [Structural suggestions](#structural-suggestions) below. +5. 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. + +The interceptor uses `registry.subscribe` rather than a React `useSelect` because (a) it must run synchronously after each dispatch, before any re-render serializes the now-wrong state, and (b) `subscribe` also catches dispatches from non-React paths. + +### Structural suggestions + +Inserting, removing, and moving blocks are captured as suggestions, not applied to the post. The interceptor follows the same "keep the store at baseline" principle as attribute edits — it **reverts the structural mutation and tags the block** with a `metadata.suggestion` marker, so the canvas keeps showing blocks at their baseline positions with a pending treatment until the change is accepted or rejected: + +| User action | Interceptor response | Persisted op | Reject undoes by | +|-------------|----------------------|--------------|------------------| +| Delete a block | Re-inserts the subtree from the previous-tick snapshot at its prior parent + index, tags it `pending-remove` | `block-remove` (carries the serialized `block`) | clearing the marker (the block stays) | +| Insert a block | Leaves the new block in place, tags it `pending-insert` (it has no baseline to revert to) | `block-insert-after` (with `anchorClientId` / `parentClientId`) | dispatching `removeBlock` | +| Move a block | Moves it back to its original position, tags it `pending-move` with the from/to anchors | `block-move` (`from*` / `to*` anchor + index fields) | dispatching `moveBlockToPosition` back | + +Each marker is written into the overlay so auto-save persists the corresponding structural operation as its own note (attribute-set ops can ride along in the same payload, but the structural op leads). Apply dispatches the real block-editor action (`removeBlock` / `insertBlock` / `moveBlockToPosition`); both Apply and Reject finish by clearing the `metadata.suggestion` marker via `clearSuggestionMarkerAttributes`. + +### Apply-time bypass and the collaborative round-trip + +Apply is a deliberate exception to the "store is never written" rule: when the post author clicks **Apply**, the merged attributes do need to land on the live block. The provider opts the next dispatch out of interception via `requestInterceptorBypass(clientId)` — without it, the interceptor would treat the apply as a new user edit and revert it back into the overlay, producing a frustrating feedback loop. + +In real-time collaboration the same scenario plays out across peers. When peer A clicks Apply, the dispatched attribute change syncs to peer B (the original suggester). Peer B's interceptor sees a delta from its own snapshot and would revert it, which would then sync back to peer A and undo the apply on their screen. To prevent this the interceptor calls `isAcceptedSuggestionChange()`: for each note linked to the block via `metadata.noteId`, it consults the suggestion payload and checks whether every changed attribute lands on a payload's `after` value. If so, the interceptor adopts the new attributes as its baseline rather than reverting. + +The two halves are complementary — `requestInterceptorBypass` covers the local apply, `isAcceptedSuggestionChange` covers the synced apply on the other peer. + +### Implementation files + +The Suggest-mode subsystem lives in `packages/editor/src/components/suggestion-mode/`: + +| File | Role | +|------|------| +| `index.js` | Barrel that re-exports the subsystem's public surface and registers the inline format types on import. | +| `constants.js` | Shared constants (`EDITOR_STORE_NAME`, `SUGGEST_INTENT`) referenced by name to avoid a module cycle with the editor store. | +| `overlay-context.js` | `SuggestionOverlayProvider`, `useSuggestionOverlay`. The in-memory overlay store and bypass refs. | +| `with-suggestion-overlay.js`| `editor.BlockEdit` HOC that diverts `setAttributes` into the overlay; renders inline ``/`` marks for text attributes and the `editor.BlockListBlock` filter for pending-state classes. | +| `inline-formats.js` | Registers the `gutenberg/suggested-deletion` / `gutenberg/suggested-addition` RichText formats; `markContentDiff` / `stripSuggestionMarks` for the inline preview. | +| `store-interceptor.js` | Snapshot/diff/revert subscriber for store-level mutations (attribute and structural); multi-peer accept logic. | +| `provider.js` | `useSuggestionsProvider` — the `createSuggestion` / `applySuggestion` / `rejectSuggestion` API. Owns `operationsFromOverlay`, `applyOperations`, `hasAttributeConflict`, `findStructuralOp`, `clearSuggestionMarkerAttributes`, `parseSuggestionPayload`, and the wrapper-aware equality check. | +| `suggestion-diff.js` | Inline diff preview rendered in a comment thread (word-level for text attributes, label fallback otherwise). | +| `suggestion-summary.js` | Compact sidebar summary ("Add: …", "Delete: …", "Format: …") used in collapsed thread lists. | +| `auto-save.js` | Debounced background persistence of pending overlays as note comments (replaces the explicit "Submit" affordance from earlier phases). | + +REST/PHP surface lives in `lib/compat/wordpress-6.9/`: + +| File | Role | +|------|------| +| `block-comments.php` | Registers the `_wp_note_status`, `_wp_suggestion`, and `_wp_suggestion_status` comment meta and adds `editor.notes` post-type support. | +| `class-gutenberg-rest-comment-controller-6-9.php` | REST controller subclass remapping permissions for `note`-type comments (post editors get `edit_post`-based access; updates are gated by an allowlist of suggestion-lifecycle fields). | + +## Suggestion Payload (v2) + +Stored as a JSON string in the `_wp_suggestion` comment meta on a `note` comment: + +```json +{ + "schemaVersion": 2, + "blockName": "core/paragraph", + "baseRevision": "2026-04-15T12:34:56", + "operations": [ + { + "type": "attribute-set", + "attribute": "content", + "before": "Hello world", + "after": "Hello beautiful world" + } + ] +} +``` + +| Field | Purpose | +|-------|---------| +| `schemaVersion` | Allows future schema evolution without breaking old payloads. | +| `blockName` | Safety check — apply is refused if the block type has changed. | +| `baseRevision` | `post_modified_gmt` at capture time. A mismatch at apply time triggers a staleness warning. | +| `operations` | Declarative transforms on the block tree. v1 emitted `attribute-set` only; v2 adds the structural variants (`block-insert-after`, `block-remove`, `block-move`), tracked in [#77434](https://github.com/WordPress/gutenberg/issues/77434). | + +Operations are **declarative transforms**, not HTML diffs. This makes them compatible with Yjs attribution semantics and resilient to concurrent edits on unrelated attributes. + +A payload carries at most one structural op (the auto-save loop persists each structural mutation as its own note); `attribute-set` ops may ride along but the structural op leads. The op types and their distinguishing fields: + +| `type` | Fields beyond `type` / `blockName` | Apply dispatches | +|--------|------------------------------------|------------------| +| `attribute-set` | `attribute`, `before`, `after` | `updateBlockAttributes` | +| `block-remove` | the serialized `block` | `removeBlock` | +| `block-insert-after`| `anchorClientId`, `parentClientId`, the serialized `block` | `insertBlock` | +| `block-move` | `fromAnchorClientId` / `fromParentClientId` / `fromIndex`, `toAnchorClientId` / `toParentClientId` | `moveBlockToPosition` | + +### v1 → v2 compatibility + +The shape of a v1 payload is a strict subset of v2 (only `attribute-set` operations). v1 payloads are migrated forward in `parseSuggestionPayload` by stamping `schemaVersion: 2` — no rewriting needed. The bump matters because a v1 reader that encountered a v2 payload with structural ops would silently drop them at apply time; refusing the payload outright surfaces an explicit "newer editor" notice and offers only Reject. + +### Schema versioning + +`schemaVersion` is incremented whenever the payload shape changes. Consumers apply the following rule: + +| Parsed version vs. consumer's known version | Behavior | +|---|---| +| `parsed < known` | Migrate the payload forward to the current shape before applying. Migrations are additive: missing fields are filled with defaults. | +| `parsed === known` | Apply normally. | +| `parsed > known` | Refuse to apply — show a "this suggestion was made by a newer editor" notice and offer only Reject. | + +When bumping the version, add a migration step in `parseSuggestionPayload` that lifts `parsed.schemaVersion < SCHEMA_VERSION` payloads into the current shape. Ship the bump and the migration in the same PR; do not read unknown future payloads. + +## Provider Interface + +```text +useSuggestionsProvider() → { + createSuggestion({ clientId, blockName, operations }) → Promise + updateSuggestion({ commentId, blockName, operations }) → Promise + deleteSuggestion({ commentId }) → Promise + applySuggestion({ commentId, clientId, payload }) → Promise + rejectSuggestion({ commentId, clientId, payload }) → Promise +} +``` + +The current implementation (`provider.js`) uses comment meta. A future Yjs-backed implementation would read from `AttributionManager` and write changes through the CRDT document, exposing the same methods. + +## Accept / Reject + +- **Accept** (attribute ops): runs `applyOperations(currentAttributes, payload.operations)` to produce new attributes, dispatches `updateBlockAttributes`, marks the note as resolved with `_wp_suggestion_status = 'applied'`. +- **Accept** (structural ops): dispatches the corresponding block-editor action — `removeBlock` for `block-remove`, `insertBlock` for `block-insert-after`, `moveBlockToPosition` for `block-move` — then clears the `metadata.suggestion` marker via `clearSuggestionMarkerAttributes`. +- **Reject**: marks the note as resolved with `_wp_suggestion_status = 'rejected'` and clears any `metadata.suggestion` marker. For structural suggestions it also undoes the in-canvas pending state: `block-insert-after` runs `removeBlock`, `block-move` runs `moveBlockToPosition` back to the original spot, `block-remove` simply drops the marker (the block was never actually removed). Attribute rejects make no content change. +- **Conflict detection**: accept-time staleness is checked at the attribute level, not the post level. `hasAttributeConflict(currentAttributes, operations)` compares each operation's captured `before` to the block's current value; only a real divergence on a targeted attribute prompts the "apply anyway" confirmation. (`block-insert-after` is exempt — its baseline is `{}`, so a comparison against the already-typed-into block would always read as divergence.) Post-level `baseRevision` is still stamped into the payload for provenance, but does not drive the prompt — every auto-save bumps `post_modified_gmt`, so a post-level compare would flag nearly every suggestion as stale. + +## Review UI + +In the notes sidebar, a suggestion thread renders: + +- **`SuggestionSummary`** — a Docs-style "Add: …", "Delete: …", "Format: …" summary derived from the operations. +- **Accept / Reject icon buttons** — checkmark and close icons that trigger the provider's apply/reject flows. +- **`SuggestionDiff`** (still available) — the full word-level diff preview for when a more detailed view is needed. + +## Yjs v2 Migration Path + +When PR [#77005](https://github.com/WordPress/gutenberg/pull/77005) (Yjs v14 / `AttributionManager`) stabilizes: + +1. Create `yjs-provider.js` implementing the same `useSuggestionsProvider` interface. +2. `createSuggestion` → write attributed changes to the Yjs doc instead of comment meta. +3. `applySuggestion` / `rejectSuggestion` → accept/reject attributed changes in the Yjs doc, then persist the resolution to comment meta for non-RTC users. +4. The overlay and diff UI remain unchanged — they consume operations, not storage details. + +Server-side persistence (comment meta) is still needed for users without RTC, so the comment-meta provider won't be fully retired — it becomes the fallback for non-collaborative sessions. + +## Implementation wrinkles worth knowing + +These are non-obvious quirks reviewers should keep in mind when reading the code: + +- **RichTextData / wrapper-vs-primitive comparison**: text-valued block attributes (notably `core/paragraph`'s `content`) are wrapped in `RichTextData` objects whose payload sits in private class fields. Plain `Object.keys()` reflection returns empty arrays for these wrappers, so a deep structural comparison would consider every wrapper "different from itself" after a JSON round-trip. The provider's `isAttributeEqual` and the interceptor's `shallowAttributeEquals` detect the wrapper-vs-primitive case and fall back to `String(a) === String(b)`. Without this, every suggestion would be flagged stale or trigger an apparent attribute conflict on apply. +- **`DEEP_MERGE_KEYS` (object-valued attributes)**: `setAttributes({ style: { color: 'red' } })` semantically replaces the whole `style` object on the live block. The overlay HOC instead does a one-level-deep merge for keys in `DEEP_MERGE_KEYS` (`style`, `metadata`) so that editing `style.color` preserves untouched fields like `style.fontSize`. Other attribute types are replaced wholesale, matching core `setAttributes` semantics. Add a key to `DEEP_MERGE_KEYS` only when the attribute is reliably a flat object. +- **Comment status vs. suggestion status**: a note comment's WP status (`hold` / `approved`) tracks whether the discussion is open or resolved. `_wp_suggestion_status` (`pending` / `applied` / `rejected`) is a parallel axis tracking the suggestion lifecycle. The two are independent: a resolved suggestion can leave its comment thread open for follow-up discussion. +- **Payload size limit**: both the client (`PAYLOAD_MAX_BYTES` in `provider.js`) and the server (`GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES` in `block-comments.php`) cap payloads at 64 KB. The client check rejects oversized payloads before they leave the browser; the REST controller is the authoritative gate. The meta `sanitize_callback` rejects (rather than truncates) oversized values because mid-string truncation produces invalid JSON that `parseSuggestionPayload` would silently drop. + +## Known Limitations + +- **Sub-attribute anchoring**: a suggestion targets a whole attribute (`before` → `after`), not an anchored sub-range. The inline ``/`` marks are a display-only rendering of that whole-attribute diff, not independently anchored spans. So if the author edits the same attribute while a suggestion is pending, the captured `before` no longer matches and Apply overwrites the interim edit (after a staleness confirmation) rather than merging it. True fragment-level, edit-resilient suggestions depend on the inline-annotation / Yjs attribution infrastructure tracked separately — see [Yjs v2 Migration Path](#yjs-v2-migration-path). +- **Permissions**: the Gutenberg REST comment controller overrides `update_item_permissions_check` so users with `edit_post` on the parent can update note comments — **but only for suggestion-lifecycle fields** (`status` limited to `approved`/`hold`, plus `meta._wp_suggestion_status`). Any other field in the update body falls back to core's `edit_comment` check, preventing post editors from rewriting another user's note content. The `_wp_suggestion` and `_wp_suggestion_status` meta `auth_callback`s follow the same `edit_post`-on-parent pattern. +- **Payload size**: `_wp_suggestion` meta is capped at 64 KB via a `sanitize_callback`. Requests exceeding that limit are rejected (the callback returns an empty string), not truncated — mid-string truncation would produce invalid JSON that `parseSuggestionPayload` would silently drop. +- **Rich-text format fidelity**: the word-level diff operates on the serialized HTML string, which may produce noisy diffs when formatting (bold, links) changes. Progressive enhancement planned. diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md index 3191956f8d99b0..cc795df4c817b0 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,20 @@ _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 intent is _session-scoped_ — held in the editor reducer (not the preferences store), so reloading the editor always returns to `edit`. Persisting suggest/view across reloads surprises users who don't realize they left the editor in a non-default state. + +Unknown intents are silently rejected (no dispatch, no announcement) so typos from a bookmarklet, browser extension, or third-party plugin can't poison the editor state; 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/docs/superpowers/plans/2026-06-16-suggest-mode-inline-markers.md b/docs/superpowers/plans/2026-06-16-suggest-mode-inline-markers.md new file mode 100644 index 00000000000000..9299c1f3d2c3a6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-suggest-mode-inline-markers.md @@ -0,0 +1,291 @@ +# Plan: Inline Suggestions on the shared inline-marker primitive (PR #78218) + +- **Status:** Proposed (2026-06-16) +- **Decision:** Option B confirmed — suggested inline changes live as marked text in block content (not in an ephemeral overlay). +- **Depends on:** [#78218](https://github.com/WordPress/gutenberg/pull/78218) "Notes: inline (partial-text) notes via hybrid marker + strip-on-render". (Its base is `trunk`; [#75147](https://github.com/WordPress/gutenberg/pull/75147) multiple-notes-per-block already merged.) +- **Supersedes:** the bespoke inline-marking in [#77869](https://github.com/WordPress/gutenberg/pull/77869) (`suggestion-mode/inline-formats.js`). +- **Resolves / addresses:** [#73411](https://github.com/WordPress/gutenberg/issues/73411) (Riad's "brittle baseline" objection), [#79220](https://github.com/WordPress/gutenberg/issues/79220) (multi-author overlay collision). +- **Forward-compat target:** Yjs `AttributionManager` ([#77005](https://github.com/WordPress/gutenberg/issues/77005)). + +--- + +## 1. Motivation + +Today an inline suggestion is a **whole-attribute before/after snapshot**: the overlay +(`suggestion-mode/overlay-context.js`, keyed by `clientId`) holds the clean proposed value, +and the visible `/` diff is **recomputed on every render** by +`markContentDiff(baseline, proposed)` (`inline-formats.js:159`), gated on `!isSelected` +(`with-suggestion-overlay.js:302`), then discarded. Nothing about the diff is persisted; only +the clean payload (`provider.js` `SuggestionPayload`, `operations[]`) crosses the wire. + +Riad Benguella's review of the stack (#73411, 2026-06-16) names the core flaw: a `before`/`after` +attribute baseline becomes **inapplicable the moment an unrelated edit lands in the same block**. +If A suggests `"quick brown fox" → "quick red fox"` and B independently edits the block to +`"very quick brown fox"`, A's suggestion can no longer apply without overwriting B's "very". He +asks that inline suggestions instead **depend on the inline-comment anchoring primitive, and that +the primitive land first**. + +PR #78218 is that primitive. Its spine: + +1. A `core/note` RichText format serializing as `…` + (`collab-sidebar/format.js`). +2. **Offsets derived on read**, never stored — `findNoteRange(value, noteId)` re-scans the + rich-text `formats` array for the marker (`collab-sidebar/utils.js:147`). This is the single + offset-resolution chokepoint and the explicit future-CRDT swap point. +3. Runtime decoration via `@wordpress/annotations` (`useAnnotateBlocks`, `hooks.js:562`) — never + written back to content. +4. Marker stripped from front-end output by a `render_block` PHP filter + (`gutenberg_strip_inline_note_markers`, `lib/compat/wordpress-7.1/block-comments.php:84`); + marker kept in raw `post_content`/REST `raw`/revisions. +5. Auto-delete when the marked text is removed — `reconcileInlineNoteMarker` (anchor/delete/skip + + a session-`Set` guard, `hooks.js:655`). + +Adopting this model for suggestions converts an inline suggestion from "the whole attribute +changed" into "**this anchored range is suggested for deletion / this inserted run is suggested +for addition**", re-resolved against current content. That is the point of this plan. + +## 2. Goals / non-goals + +**Goals** + +- Anchor inline suggestions to edit-surviving markers; derive positions on read; never persist + absolute offsets. +- Make suggested inline text **live in block content** so it syncs via CRDT, survives reload, and + is visible to all collaborators (Option B). +- Support **N concurrent, per-author** inline suggestions on one block (dissolve #79220). +- Carry `authorId` on every marker end-to-end (hydrate → render → resolve). +- Keep a single localized swap point for the eventual Yjs migration. +- Reuse #78218's primitive rather than maintaining a parallel system. + +**Non-goals** + +- Block-level structural suggestions (remove/insert/move) — unchanged; they don't use inline marks. +- The Notes feature itself — we generalize its helpers but do not change Notes behavior. +- The Yjs/`AttributionManager` migration — only kept a clean swap point. + +## 3. The decision: marks-in-content (Option B) + +| | A. Overlay + anchoring | **B. Marks in content (chosen)** | +|---|---|---| +| Deletion | Anchor a marker, keep proposed value in overlay | Wrap existing text in a `del` marker; strip wrapper-only on render (text stays until accepted) | +| Addition | Inject `` ephemerally at render (today) | Insert proposed text in an `add` marker; strip **wrapper + text** on render until accepted | +| Multi-author | Still needs per-author overlay re-keying | Each marker independent + author-tagged → #79220 dissolves | +| Sync/reload | Proposed text not synced; recomputed per client | Suggested text syncs via CRDT, survives reload, visible to all | +| Cost | Lighter; partial answer to Riad | Heavier; render/save must strip un-accepted added text | + +**Chosen: B.** It is the only option that fully satisfies "anchor, don't snapshot", makes additions +multi-author-correct for free, and matches #78218's philosophy. Its distinctive cost is the +**addition-strip semantics** below — the main net-new work beyond reuse. + +### Render/save strip semantics (the net-new piece) + +#78218 strips the wrapper but keeps inner text (a note annotates existing text). Suggestions need +**type-aware** stripping at `render_block`: + +- **Deletion mark** (`data-suggestion-type="del"`): strip the wrapper, **keep** the inner text. + The text is not removed until the suggestion is accepted. +- **Addition mark** (`data-suggestion-type="add"`): strip the wrapper **and** the inner text. The + proposed addition must never reach the published front-end until accepted. + +Acceptance flips it: accept `del` → remove text + marker; accept `add` → unwrap (text becomes +permanent). Reject: `del` → remove marker only; `add` → remove marker + text (and auto-delete the +suggestion). + +## 4. Architecture + +### 4.1 Shared inline-marker primitive (generalized from #78218) + +Extract a format-agnostic module — `packages/editor/src/components/inline-markers/` — consumed by +**both** Notes and Suggestions: + +| New (generic) | Generalized from (#78218) | Notes | +|---|---|---| +| `findMarkerRange(value, { formatType, idAttribute, id })` | `findNoteRange` (`utils.js:147`) | **Single offset-resolution / CRDT swap point.** | +| `wrapInlineMarker(value, { formatType, attributes, start, end })` | `wrapInlineNote` (`hooks.js:446`) | `applyFormat` over `RichTextData`. | +| `readInlineSelection(getSelectionStart, getSelectionEnd)` | same (`hooks.js:409`) | Already generic; move as-is. | +| `reconcileMarkerRemoval(item, attributes, anchoredSet, { getId, onDelete })` | `reconcileInlineNoteMarker` (`hooks.js:655`) | anchor/delete/skip + session-`Set` guard. | +| `useAnnotateRanges({ source, ranges })` | `useAnnotateBlocks` (`hooks.js:562`) | Parameterize the hard-coded `source='core-note'`; each consumer gets its own `annotation-text-{source}` class. | + +Refactor Notes to consume the generic module with **no behavior change** (protects #78218's +approval). **Do not** carry over the `_wp_note_selection` meta-offset fallback — ellatrix has an +open objection to it on #78218, and "derive on read" means the in-content marker is the single +source of truth. + +### 4.2 Suggestion marker format + +Register a distinct format (separate from `core/note` so the two coexist on one block): + +``` +… +``` + +- Distinct class `wp-suggestion` (exact token) so the PHP strip and CSS never collide with + `wp-note` or a user/`core/text-color` ``. +- `data-author` makes per-author tint and attribution structural (the #77869 Q1 fix, now built in). +- `data-suggestion-id` links to the persisted suggestion (the `note`-type comment id), mirroring + #78218's identity linkage; offsets are always derived, never stored. + +### 4.3 Decoration + +Decorate via `useAnnotateRanges({ source: 'core-suggestion', ranges })` — runtime-only, never +written to content. CSS targets `.annotation-text-core-suggestion` (independent of Notes' +`.annotation-text-core-note`). Phase 5 aligns the add/remove visual language with the Revisions +diff UI (annezazu, #73411). + +### 4.4 Strip filter (PHP) + +A `render_block` filter in `lib/compat/wordpress-X.Y/`, analogous to +`gutenberg_strip_inline_note_markers`, but **type-aware** (§3): `del` strips wrapper-only, `add` +strips wrapper + text. Reuse #78218's two-pass `WP_HTML_Tag_Processor` + offset technique (it +already handles overlap/nesting); inherit the same temporary-hack caveat pending HTML API tag +removal ([#54583](https://github.com/WordPress/gutenberg/issues/54583), cc dmsnell). + +### 4.5 Persistence & CRDT + +`provider.js` keeps the finite, schema-enforced op set (Riad's "keep it bounded"). The inline ops +shift from carrying whole-attribute `before`/`after` to **anchored ranges keyed by marker id**; +`findMarkerRange` is the only place positions are resolved, so the Yjs `AttributionManager` swap +(#77005) stays a single localized change. The `note`-type comment + `metadata.noteId` array +linkage (already in the stack via #75147) is reused. + +### 4.6 Multi-author (#79220) + +In Option B each suggestion is an independent, identity-keyed, author-tagged marker in content, so +the single-slot `entries[clientId]` overlay (`overlay-context.js:114`) is no longer the inline +source of truth — the structural fix for #79220. Any residual in-progress-edit overlay keys by +**suggestion identity**, not bare `clientId`. + +## 5. Branch topology & sequencing + +#78218 is `trunk`-based with no hidden unmerged dependency; it shares a recent trunk merge-base +with the combined branch. So we **do not wait for #78218 to merge** — and we **do not rebase the +stack** (it is merge-assembled; rebasing merge commits is destructive, and only the inline layer +needs #78218). + +Two mechanisms, two purposes: + +1. **Combined testing branch (#78994) → MERGE `add/inline-notes-hybrid` in.** Matches combined's + existing "Merge X stack into combined branch" pattern. After this, combined has #78218 + all + suggest-mode phases, so Option-B work is developed and Playground-tested here now. Expect a + real hand-resolved merge (both #78218 and #77869 touch `collab-sidebar/index.js` format + registration, `collab-sidebar/hooks.js`/`utils.js`, `content-suggestion.scss`, and the notes + data model). Resolving it early is itself the Phase 0–1 consolidation. +2. **Inline-suggestions phase branch → STACK on `add/inline-notes-hybrid`.** PR base = + `add/inline-notes-hybrid` while #78218 is open; **retarget to `trunk` once #78218 merges** (the + same move #78218 made off #75147). When #78218 changes in review, rebase this one branch onto + the new tip — normal stacked maintenance, not a stack-wide rebase. + +The structural phases (remove/insert/move, autosave, move-ghost) keep their current base. + +## 6. Phased delivery + +### Phase 0 — Unblock + extract the shared primitive +- Merge `add/inline-notes-hybrid` into combined (#78994); resolve the notes/collab-sidebar/inline + conflicts. +- Extract `inline-markers/` (§4.1); refactor Notes to consume it with no behavior change. +- Omit the `_wp_note_selection` meta fallback from the primitive. +- *Acceptance:* Notes pass all #78218 tests post-refactor; the primitive has standalone unit tests. + +### Phase 1 — Suggestion marker format + decoration + strip +- Register the `wp-suggestion` format (§4.2); decorate via `useAnnotateRanges` source + `core-suggestion` (§4.3); add the type-aware PHP strip filter (§4.4). +- *Acceptance:* a hand-authored block with `del`/`add` suggestion marks renders decorated + in-editor; front-end keeps del-text, drops add-text, removes all wrappers. + +### Phase 2 — Deletion suggestions on anchored markers +- "Suggest delete" wraps the range with the `del` marker instead of recording a whole-content + `after`; resolve live range via `findMarkerRange`; accept removes text+marker, reject removes + marker. Wire through `provider.js` apply/reject. +- *Acceptance:* a deletion suggestion survives an unrelated edit elsewhere in the block and still + applies to the right range (Riad's flaw gone for deletions). + +### Phase 3 — Additions in content (net-new) +- Insert proposed text wrapped in the `add` marker; render-strip (Phase 1) removes wrapper+text on + front-end; in-editor annotations decorate it as an insertion; accept unwraps, reject removes + + auto-deletes via `reconcileMarkerRemoval`. +- **Validate autosave / undo-redo / RTC**: marked-added text now lives in synced content — confirm + autosave persists it (stripped only at front-end), undo crosses marker boundaries, and a peer + sees it via CRDT. + +### Phase 4 — Retire the bespoke layer; dissolve #79220 +- Remove `inline-formats.js` (`markContentDiff`, `stripSuggestionMarks`, `wrapAddition/Deletion`, + the two `gutenberg/suggested-*` formats) and the `applyDiffMarks` / `stripMarksFromIncoming` / + `isSelected`-gated `mergedAttributes` path in `with-suggestion-overlay.js`. Keep `wordDiff` + (`suggestion-diff.js:28`) for the sidebar. +- Move `.has-suggestion-deletion/-addition` (`content-suggestion.scss:122`) to the annotation + decoration; the block-level `is-suggestion-pending*` rules are unaffected. +- Replace/repurpose the single-slot overlay; any in-progress overlay keys by suggestion identity. +- *Acceptance:* two authors leave concurrent inline suggestions on one block, each correctly + attributed and tinted, each diff baselined to its own author (#79220). + +### Phase 5 — Visual alignment & authorship +- Align add/remove visuals with the Revisions diff UI (annezazu). +- `data-author` drives per-author tint surviving reload/reviewer view (#77869 Q1); track author + color distinguishability ([#78255](https://github.com/WordPress/gutenberg/issues/78255)). +- Inverse cleanup: marks clear when a suggestion resolves elsewhere (the #77869 Q3 pattern, via + `reconcileMarkerRemoval`). + +### Phase 6 — CRDT readiness, tests, docs +- Confirm `findMarkerRange` is the sole offset-resolution point and the op set stays finite + + schema-enforced. +- Unit tests (primitive), e2e (create / edit-around / accept / reject / multi-author), + round-trip + PHP render-strip tests. +- Update `docs/explanations/architecture/suggestions.md`. + +## 7. How review feedback is addressed + +| Feedback (source) | Addressed by | +|---|---| +| Brittle before/after baseline forces lossy overwrite (Riad, #73411) | Anchored markers + derive-on-read (Phases 1–3) | +| Inline suggestions should depend on the inline-comment primitive, land it first (Riad) | Sequenced behind #78218; Phase 0 primitive | +| Multi-author collision on one block (#79220; saroshaga #77869 Q2) | Independent identity+author-keyed markers (Phase 4) | +| Authorship must follow the mark, not the viewer (#77869 Q1) | `data-author` on every marker (Phase 5) | +| Inverse cleanup when resolved elsewhere (#77869 Q3) | `reconcileMarkerRemoval` (Phase 5) | +| Align with Revisions add/remove visuals (annezazu) | Phase 5 | +| Forward-compatible with Yjs, finite op set (Riad, #77005) | Single `findMarkerRange` swap point; bounded ops (Phase 6) | +| Drop fragile meta-offset fallback (ellatrix, #78218) | Primitive omits `_wp_note_selection` fallback (Phase 0) | + +## 8. Risks & open decisions + +- **Addition-strip semantics** (Phase 3) is the main net-new design vs #78218 — validate + render-strip + autosave + accept/reject early; it is the highest-risk item. +- **Overlapping / nested marks:** jasmussen's split-vs-nested serialization question on #78218 is + unresolved. Suggestion marks must coexist with note marks and tolerate whichever lands; the + offset scan and two-pass PHP strip already handle nesting/overlap, so this is low-risk but + coupled to #78218's final shape. +- **`@wordpress/annotations` is `__experimental`** — we consume it (allowed); we do not add new + experimental APIs. +- **PHP two-pass strip is explicitly temporary** (HTML API #54583) — inherited debt. +- **#78218 still in review** (Mamaduka's deep review pending; ellatrix meta-fallback open) — its + API may move; Phase 0 starts after the merge-into-combined and tracks #78218's tip. +- **Open decision:** whether the `inline-markers/` extraction is upstreamed into #78218 itself + (primitive ships with Notes) or carried on the inline-suggestions phase branch. Default: carry on + the phase branch; offer to upstream if maintainers prefer. + +## 9. Testing strategy + +- **Unit:** the primitive (`findMarkerRange`, `wrapInlineMarker`, `reconcileMarkerRemoval`); + Notes-unchanged regression after the refactor. +- **PHP:** `render_block` strip — del keeps text, add drops text, wrappers removed, raw/REST `raw` + retain markers; overlap/nesting cases. +- **e2e:** create del + add suggestions; edit unrelated text in the same block and confirm the + suggestion still anchors; accept/reject each type; two-author concurrent suggestions + (attribution + tint); reload persistence. +- **Round-trip:** content with suggestion marks → serialize → parse → resolve ranges unchanged. + +## 10. References + +- PRs: [#78218](https://github.com/WordPress/gutenberg/pull/78218) (primitive), + [#77869](https://github.com/WordPress/gutenberg/pull/77869) (interim inline-formats, superseded), + [#78994](https://github.com/WordPress/gutenberg/pull/78994) (combined testing), + [#75147](https://github.com/WordPress/gutenberg/pull/75147) (multiple notes per block, merged). +- Issues: [#73411](https://github.com/WordPress/gutenberg/issues/73411) (Suggest Mode tracking), + [#79220](https://github.com/WordPress/gutenberg/issues/79220) (multi-author), + [#59445](https://github.com/WordPress/gutenberg/issues/59445) (inline comments), + [#77005](https://github.com/WordPress/gutenberg/issues/77005) (Yjs AttributionManager), + [#54583](https://github.com/WordPress/gutenberg/issues/54583) (HTML API tag removal), + [#78255](https://github.com/WordPress/gutenberg/issues/78255) (author color distinguishability). +- Architecture: `docs/explanations/architecture/suggestions.md`. diff --git a/lib/compat/wordpress-7.1/block-suggestions.php b/lib/compat/wordpress-7.1/block-suggestions.php new file mode 100644 index 00000000000000..48af40660b4f75 --- /dev/null +++ b/lib/compat/wordpress-7.1/block-suggestions.php @@ -0,0 +1,112 @@ + '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 ) { + // During comment creation the comment does not yet exist, so + // `object_id` is 0. Defer to the comment controller's own + // create permission — if the request can create the + // comment at all, it can set the suggestion meta on it. + if ( ! $object_id ) { + return current_user_can( 'edit_posts' ); + } + $comment = get_comment( $object_id ); + if ( $comment && 'note' === $comment->comment_type ) { + return current_user_can( 'edit_post', $comment->comment_post_ID ); + } + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); + + 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 ) { + $comment = get_comment( $object_id ); + if ( $comment && 'note' === $comment->comment_type ) { + return current_user_can( 'edit_post', $comment->comment_post_ID ); + } + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); +} +add_action( 'init', 'gutenberg_register_suggestion_meta' ); diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php new file mode 100644 index 00000000000000..9dbb8250861b9c --- /dev/null +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -0,0 +1,238 @@ + GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES ) { + return new WP_Error( + 'rest_suggestion_too_large', + sprintf( + /* translators: %d: maximum allowed byte length. */ + __( 'Suggestion payload exceeds the %d-byte limit.', 'gutenberg' ), + GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES + ), + array( 'status' => 413 ) + ); + } + return true; + } + + /** + * Determines whether a note-update request touches only the fields used + * by the suggestion apply/reject lifecycle. + * + * Allowed fields: + * - `status` (limited to `approved` or `hold`) + * - `meta._wp_suggestion_status` + * + * Any other field present in the request body disqualifies the request + * from the `edit_post` shortcut, forcing it through core's edit_comment + * check instead. + * + * @param WP_REST_Request $request Full details about the request. + * @return bool + */ + private static function is_suggestion_lifecycle_update( $request ) { + // Accept either a JSON body (the block editor client) or a form- + // encoded body (custom integrations / curl scripts). Either way the + // shortcut is gated by the same allowlist below - query/URL params + // are intentionally excluded so the body is the source of truth for + // what's being written. + $params = $request->get_json_params(); + if ( ! is_array( $params ) ) { + $params = $request->get_body_params(); + } + if ( ! is_array( $params ) || empty( $params ) ) { + return false; + } + + $allowed_keys = array( 'id', 'status', 'meta' ); + foreach ( array_keys( $params ) as $key ) { + if ( ! in_array( $key, $allowed_keys, true ) ) { + return false; + } + } + + if ( + isset( $params['status'] ) && + ! in_array( $params['status'], array( 'approved', 'hold' ), true ) + ) { + return false; + } + + if ( isset( $params['meta'] ) ) { + if ( ! is_array( $params['meta'] ) ) { + return false; + } + $allowed_meta = array( '_wp_suggestion_status' ); + foreach ( array_keys( $params['meta'] ) as $meta_key ) { + if ( ! in_array( $meta_key, $allowed_meta, true ) ) { + return false; + } + } + } + + return true; + } + + /** + * Checks if a given request has access to update a comment. + * + * Extends core's check so that users who can `edit_post` on the parent + * post are also allowed to update note-type comments - but only for + * suggestion-lifecycle fields (status and `_wp_suggestion_status` meta). + * This unblocks the suggestion workflow where a post editor applies or + * rejects a suggestion authored by someone else, without granting them + * the ability to rewrite the note's content, reassign authorship, or + * otherwise modify another user's comment. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access, WP_Error otherwise. + */ + public function update_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + // For note comments, allow users who can edit the parent post to + // update suggestion-lifecycle fields only. + if ( + 'note' === $comment->comment_type && + self::is_suggestion_lifecycle_update( $request ) + ) { + $post = get_post( $comment->comment_post_ID ); + if ( $post && current_user_can( 'edit_post', $post->ID ) ) { + return true; + } + } + + // Fall back to core's default check (moderate_comments or edit_comment). + return parent::update_item_permissions_check( $request ); + } + + /** + * Prepares a single comment for create or update. + * + * Wraps core's preparation with two suggestion-specific concerns: + * + * - Rejects oversized `_wp_suggestion` payloads with a clean 413 before + * any storage happens (both create and update call this and return + * its WP_Error). + * - Surfaces the `_wp_suggestion` payload in the prepared `meta` so the + * content-allowed check can recognize a payload-only note, mirroring + * how core copies `_wp_note_status` for the same check. + * + * @param WP_REST_Request $request Request object. + * @return array|WP_Error Prepared comment, or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + $size_check = self::validate_suggestion_payload_size( $request ); + if ( is_wp_error( $size_check ) ) { + return $size_check; + } + + $prepared_comment = parent::prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + if ( isset( $request['meta']['_wp_suggestion'] ) ) { + if ( ! isset( $prepared_comment['meta'] ) || ! is_array( $prepared_comment['meta'] ) ) { + $prepared_comment['meta'] = array(); + } + $prepared_comment['meta']['_wp_suggestion'] = $request['meta']['_wp_suggestion']; + } + + return $prepared_comment; + } + + /** + * Allows a note comment to have empty content when it carries a + * suggestion payload. + * + * A pure suggestion (a proposed edit with no discussion text) has empty + * `comment_content`; core would otherwise reject it. Everything else + * defers to core's check. + * + * @param array $prepared_comment Prepared comment data. + * @return bool + */ + protected function check_is_comment_content_allowed( $prepared_comment ) { + if ( + isset( $prepared_comment['comment_type'] ) && + 'note' === $prepared_comment['comment_type'] && + ! empty( $prepared_comment['meta']['_wp_suggestion'] ) + ) { + return true; + } + + return parent::check_is_comment_content_allowed( $prepared_comment ); + } + } +} + +add_action( + 'rest_api_init', + function () { + // Register after core's default comments controller (priority 10) so the + // note-aware /wp/v2/comments routes are overridden with the + // suggestion-aware subclass. + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $controller->register_routes(); + }, + 11 +); diff --git a/lib/load.php b/lib/load.php index cf3df3faeb4108..35fd66e1127b77 100644 --- a/lib/load.php +++ b/lib/load.php @@ -76,6 +76,8 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-7.1/collaboration.php'; require __DIR__ . '/compat/wordpress-7.1/block-bindings.php'; require __DIR__ . '/compat/wordpress-7.1/block-comments.php'; + require __DIR__ . '/compat/wordpress-7.1/block-suggestions.php'; + require __DIR__ . '/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php'; // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; diff --git a/package-lock.json b/package-lock.json index 373b195ea529d6..07806657b3083a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46918,6 +46918,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/a11y": "file:../a11y", + "@wordpress/annotations": "file:../annotations", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/base-styles": "file:../base-styles", "@wordpress/blob": "file:../blob", diff --git a/packages/block-editor/src/components/block-list/content-suggestion.scss b/packages/block-editor/src/components/block-list/content-suggestion.scss new file mode 100644 index 00000000000000..486e4a935e92c2 --- /dev/null +++ b/packages/block-editor/src/components/block-list/content-suggestion.scss @@ -0,0 +1,150 @@ +@use "@wordpress/base-styles/colors" as *; +@use "@wordpress/base-styles/variables" as *; + +// Visual treatment for blocks tagged with a pending suggestion. The marker +// itself lives in `metadata.suggestion` (set by the editor-package suggestion +// system); this stylesheet only knows about the resulting class name. Lives in +// `block-editor` because the canvas iframe loads `wp-block-editor-content` +// (compiled from this package) but does NOT load the editor-package chrome +// stylesheets — keeping these rules here is what makes them apply inside the +// iframe. + +// Default suggestion color, used when the suggester's avatar color +// can't be resolved (e.g., anonymous edits or pre-collab sessions). +// `withSuggestionBlockClassName` writes the suggester's avatar color +// into `--suggestion-author-color` on each pending-suggestion block, +// so individual suggestions tint to their author's color the same way +// live cursors do — Google Docs-style. Rules below consume the +// variable with this green as the fallback. +$suggestion-color: #188038; +$suggestion-author-color: var(--suggestion-author-color, #{$suggestion-color}); + +// Green "bracket" applied to any block with a pending attribute overlay +// while the suggester is editing. Drawn with an outline so it layers above +// block content without affecting layout. +.block-editor-block-list__block.is-suggestion-pending { + position: relative; + outline: 2px solid $suggestion-author-color; + outline-offset: 2px; + border-radius: $radius-small; + + &::before, + &::after { + content: ""; + position: absolute; + width: $grid-unit-10; + height: $grid-unit-10; + border: 2px solid $suggestion-author-color; + pointer-events: none; + } + + &::before { + top: -$grid-unit-05; + left: -$grid-unit-05; + border-right: 0; + border-bottom: 0; + } + + &::after { + bottom: -$grid-unit-05; + right: -$grid-unit-05; + border-left: 0; + border-top: 0; + } +} + +// Pending-remove: the block is still real (selection, focus, and toolbar +// keep working) but the visual treatment communicates "proposed for +// deletion". Strikethrough + suggestion-green text on every line so +// multi-line paragraphs read clearly as "delete me", matching Google +// Docs' suggesting-mode strike-through. The decoration is applied to +// the wrapper and propagates to inline rich-text descendants. +.block-editor-block-list__block.is-suggestion-pending-remove { + color: $suggestion-author-color; + text-decoration: line-through; + text-decoration-color: $suggestion-author-color; + text-decoration-thickness: 2px; +} + +// Pending-insert: the block was inserted in Suggest mode but isn't yet +// "real". A dashed green outline distinguishes it from the solid bracket +// used by pending attribute edits, and the suggester's typed content +// is rendered in suggestion green so the reviewer sees a green preview +// of what's being added (matching Google Docs' suggesting-mode insert +// treatment). Edits to a pending-insert block bypass the suggestion +// overlay (see `with-suggestion-overlay.js`) so the content syncs and +// shows up here. +.block-editor-block-list__block.is-suggestion-pending-insert { + position: relative; + color: $suggestion-author-color; + outline: 2px dashed $suggestion-author-color; + outline-offset: 2px; + border-radius: $radius-small; +} + +// Pending-move: the block has been dragged to a new position but the move +// isn't yet committed. A dotted green outline distinguishes it from the +// dashed pending-insert (the block existed before — only its position is +// suggested). The "Suggested move" tab pinned to the top corner makes the +// preview state unambiguous. The tab text comes from the +// `data-suggestion-move-label` attribute (set by the editor-package HOC) so +// it is translatable rather than a hardcoded English string baked into CSS. +.block-editor-block-list__block.is-suggestion-pending-move { + position: relative; + opacity: 0.7; + outline: 2px dotted $suggestion-author-color; + outline-offset: 2px; + border-radius: $radius-small; + + &::before { + content: attr(data-suggestion-move-label); + position: absolute; + top: -$grid-unit-20; + left: -$grid-unit-05; + padding: 0 $grid-unit-10; + background: $suggestion-author-color; + color: $white; + font-size: 11px; + font-weight: 500; + line-height: $grid-unit-20; + border-radius: $radius-small $radius-small 0 0; + pointer-events: none; + z-index: 1; + } +} + +// Move ghost: a non-interactive placeholder rendered at a moved block's +// original position (by the editor-package suggestion HOC) so a reviewer +// can see where the block came from. Dimmed + struck-through, tinted with +// the suggester's color (shared with the decorated block at the destination +// via `--suggestion-author-color`), with a small "Moved from here" label so +// the ghost reads as one half of a move pair. +.is-suggestion-move-ghost { + display: block; + position: relative; + margin: $grid-unit-15 0; + padding: $grid-unit-15; + opacity: 0.6; + color: $suggestion-author-color; + border: 1px dotted $suggestion-author-color; + border-radius: $radius-small; + text-decoration: line-through; + text-decoration-color: $suggestion-author-color; + pointer-events: none; + user-select: none; + + .is-suggestion-move-ghost__label { + display: inline-flex; + align-items: center; + gap: $grid-unit-05; + font-size: 11px; + font-weight: 500; + text-decoration: none; + } + + .is-suggestion-move-ghost__excerpt { + display: block; + margin-top: $grid-unit-05; + font-size: 13px; + } +} diff --git a/packages/block-editor/src/content.scss b/packages/block-editor/src/content.scss index 4f70f2df4271a6..913477aaa45a6b 100644 --- a/packages/block-editor/src/content.scss +++ b/packages/block-editor/src/content.scss @@ -2,6 +2,7 @@ @use "@wordpress/base-styles/mixins" as *; @use "./components/block-icon/content.scss" as *; @use "./components/block-list/content.scss" as *; +@use "./components/block-list/content-suggestion.scss" as *; @use "./components/block-list-appender/content.scss" as *; @use "./components/block-content-overlay/content.scss" as *; @use "./components/block-draggable/content.scss" as *; diff --git a/packages/edit-post/src/index.js b/packages/edit-post/src/index.js index 5753ac94b934ea..71e6108635ec0e 100644 --- a/packages/edit-post/src/index.js +++ b/packages/edit-post/src/index.js @@ -78,6 +78,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 f4ce8f3fbb9e35..4366332fae2253 100644 --- a/packages/edit-site/src/index.js +++ b/packages/edit-site/src/index.js @@ -74,6 +74,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 26f8dad18dea65..bf2b875ecfd9b9 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -2,6 +2,12 @@ ## 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. +- 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. Pending overlay edits auto-save as a note comment with a `_wp_suggestion` meta payload (`schemaVersion`, `blockName`, `baseRevision`, `operations`) after a short idle window, and subsequent edits on the same block update the existing note rather than creating a new one. Blocks with a pending suggestion are marked with a green bracket/outline, and the notes sidebar shows a Docs-style 'Add / Delete / Format' summary with checkmark-and-close icon buttons to accept or reject. +- `setEditorIntent` now surfaces mode transitions with a snackbar ('You're suggesting' / 'You're editing' / 'You're viewing') alongside the existing a11y announcement. + ## 14.49.0 (2026-06-24) ## 14.48.1 (2026-06-16) diff --git a/packages/editor/package.json b/packages/editor/package.json index 2882230a163366..7237eea202207f 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -60,6 +60,7 @@ ], "dependencies": { "@wordpress/a11y": "file:../a11y", + "@wordpress/annotations": "file:../annotations", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/base-styles": "file:../base-styles", "@wordpress/blob": "file:../blob", diff --git a/packages/editor/src/components/collab-sidebar/format.js b/packages/editor/src/components/collab-sidebar/format.js index be3822c35ca197..79d0e30b6eab1c 100644 --- a/packages/editor/src/components/collab-sidebar/format.js +++ b/packages/editor/src/components/collab-sidebar/format.js @@ -32,6 +32,12 @@ function NoteFormatEdit( { value, isActive, activeAttributes } ) { // Static selector getter: the active area is only read when the button is // clicked, so there's no need to subscribe and re-render on its changes. const { getActiveComplementaryArea } = useSelect( interfaceStore ); + // Subscribe to the selected note so the button can render active while a + // new note/suggestion is being composed ( `selectedNote === 'new'` ). + const selectedNote = useSelect( + ( select ) => unlock( select( editorStore ) ).getSelectedNote(), + [] + ); // Toolbar button only relevant on an active selection or when standing on // an existing inline note marker. @@ -66,7 +72,7 @@ function NoteFormatEdit( { value, isActive, activeAttributes } ) { icon={ commentIcon } title={ __( 'Add note' ) } onClick={ onClick } - isActive={ isActive } + isActive={ isActive || selectedNote === 'new' } /> ); } diff --git a/packages/editor/src/components/collab-sidebar/note.js b/packages/editor/src/components/collab-sidebar/note.js index 1b9645590a59d3..55b4cac8393128 100644 --- a/packages/editor/src/components/collab-sidebar/note.js +++ b/packages/editor/src/components/collab-sidebar/note.js @@ -22,6 +22,9 @@ import { moreVertical, published } from '@wordpress/icons'; */ import { NoteCard } from './note-card'; import { NoteForm } from './note-form'; +import SuggestionActions, { + SuggestionActionButtons, +} from './suggestion-actions'; import { unlock } from '../../lock-unlock'; const { Menu } = unlock( componentsPrivateApis ); @@ -90,7 +93,11 @@ export function Note( { } }, [ rawContent ] ); - const canResolve = note.parent === 0; + // Suggestion threads expose their own Accept/Reject affordance in the + // header; the generic "Resolve" button would duplicate that action with + // a confusingly similar checkmark icon, so hide it for suggestion notes. + const hasSuggestionPayload = !! note?.meta?._wp_suggestion; + const canResolve = note.parent === 0 && ! hasSuggestionPayload; const isResolutionNote = note.type === 'note' && note.meta && @@ -190,9 +197,13 @@ export function Note( { ); } - const actions = isSelected ? ( + const showActions = isSelected || hasSuggestionPayload; + const actions = showActions ? ( <> - { canResolve && onResolve && ( + { hasSuggestionPayload && ( + + ) } + { isSelected && canResolve && onResolve && (