Skip to content

Add editor-preview eye button with live split-view preview - #398

Merged
AllTerrainDeveloper merged 7 commits into
trunkfrom
add/editor-preview-eye-button
Jul 27, 2026
Merged

Add editor-preview eye button with live split-view preview#398
AllTerrainDeveloper merged 7 commits into
trunkfrom
add/editor-preview-eye-button

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

Post/page/CPT editor windows (Gutenberg and classic) get a Preview (eye) title-bar button. Clicking it:
image

  1. Autosaves the editor over the bridge (same mechanism as Gutenberg's own Preview button — __unstableSaveForPreview()), so the preview reflects on-screen content.
  2. Snaps the editor to the left half and opens the official front-end preview (get_preview_post_link(), autosave-aware + nonce'd) as a companion window snapped right.

The editor↔preview pairing then keeps itself correct:

  • Live typing updates — the editor iframe watches its own content (typing detection can't cross the frame boundary) and, 1.5 s after a typing pause, autosaves and nudges the shell to refresh the companion. Guarded against save-feedback loops (absorb save churn, dirty gate, autosaveable gate); classic editor rides core's own autosave ticks. Tunable/disableable via the desktop-mode.editor-preview.live filter.
  • Silent double-buffered refresh — new Window.swapReload(): the fresh render loads into a twin iframe underneath the visible one (fully rasterized while covered) and cuts over in one tick on load. No loading overlay, no white flash, scroll position preserved. Save-driven reloads (broadcast bus: Gutenberg save-watcher, classic footer emitter, heartbeat catch-up) share the same path.
  • Lifecycle — editor close destroys the companion; preview close only unpairs; navigating to different content closes it; second eye click toggles off (aria-pressed). The companion is ephemeral (new WindowConfig flag) — never session-restored, its preview nonce wouldn't survive.
  • Unsaved "Add New" screens show the eye disabled with a save hint; the first save enables it live via the identity refetch.
  • Focus fix — bridge-less same-origin iframe documents (front-end preview, home default window) now forward pointerdown to the shell focus path, so clicking anywhere inside the preview focuses its window (previously only the title bar worked).

Server side

desktop_mode_window_preview_url() attaches previewUrl to post-editor content identities (page render + REST recompute), gated on is_post_type_viewable() + current_user_can( 'edit_post' ), filterable via the new desktop_mode_window_preview_url filter. The client engine only accepts same-origin values.

Protocol & docs

New typed bridge messages: desktop-mode-editor-autosave-request/-response, desktop-mode-editor-live-watch/-unwatch/-saved — catalogued in src/protocol/window-messages.ts, documented in docs/bridge-protocol.md. JS surface (EDITOR_PREVIEW_* hooks, the live filter, swapReload, ephemeral) in docs/javascript-reference.md; PHP filter in docs/hooks-reference.md; events/messages indexed in docs/api-index.md.

Testing

  • PHPUnit: previewUrl in identity (draft + published + nonce verify), non-viewable CPT exclusion, filter rewrite/suppress, REST recompute (Tests_DesktopMode_WindowLinks, 31 passing; full suite 1335 green).
  • Vitest: 4 new/extended suites — button match/click/toggle/pairing lifecycle, save-driven + live reload scheduling, iframe-side autosave answerer + live watcher (incl. the four loop-guard regressions), swapReload mechanics (instant cut, supersede, overlay contract, focus forwarder survival), ephemeral snapshot exclusion. Full suite: 2209 passing across 234 files.
  • Manually verified on the dev stack: Gutenberg + classic, draft + published, split-view snap, live typing refresh without flicker, no runaway autosaves, session restore, focus behavior.

🤖 Generated with Claude Code

Open WordPress Playground Preview

AllTerrainDeveloper and others added 4 commits July 23, 2026 14:18
Post/page/CPT editor windows get a "Preview" (eye) title-bar button.
Clicking it autosaves the editor over the bridge, snaps it to the left
half, and opens the official front-end preview
(get_preview_post_link(), autosave-aware + nonce'd) as a companion
window snapped right. The pairing keeps the preview current and cleans
itself up:

- Live typing updates: the editor iframe watches its own content and,
  1.5s after a typing pause, autosaves (__unstableSaveForPreview) and
  nudges the shell to refresh — with three feedback-loop guards so the
  watcher's own saves never read as fresh edits (absorb save churn,
  dirty gate, autosaveable gate). Classic editor rides core's own
  autosave ticks.
- Silent double-buffered refresh: new Window.swapReload() loads the
  fresh render into a twin iframe UNDERNEATH the visible one (fully
  rasterized, covered while loading) and cuts over in one tick on
  load — no loading overlay, no white flash, scroll preserved.
  Save-driven reloads (broadcast bus) share the same path.
- Pairing lifecycle: editor close destroys the companion, preview
  close only unpairs, navigating to different content closes it,
  second eye click toggles off (aria-pressed tracked). Companion is
  ephemeral (new WindowConfig flag) — never session-restored, its
  nonce wouldn't survive.
- Unsaved "Add New" screens show the eye disabled with a save hint;
  the first save enables it live via the identity refetch.
- Bridge-less same-origin iframe documents (front-end preview, home
  default window) now forward pointerdown to the shell focus path, so
  clicking anywhere in the preview focuses its window.

Server side: desktop_mode_window_preview_url() attaches previewUrl to
post-editor content identities (page render + REST recompute), gated
on viewability + edit_post, filterable via
desktop_mode_window_preview_url. New bridge messages
(editor-autosave-request/response, editor-live-watch/unwatch/saved)
are catalogued in the protocol and documented in bridge-protocol.md;
JS surface (EDITOR_PREVIEW_* hooks, live filter, swapReload,
ephemeral) in javascript-reference.md; PHPUnit + Vitest coverage
throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015x434YzuoS1aXywkaS55St
…nion

Two gaps found reviewing #398:

- Window.swapReload(): the promoted twin re-wired the overlay contract
  and the focus forwarder, but not the submenu tab-sync `load` listener
  from construction — any window with submenu tabs stopped highlighting
  the active tab after its first swap. Extracted `_wireTabNavSync()`,
  shared by construction and the swap promotion (which also syncs once
  for the already-fired load).

- onEyeClick(): the pairing is recorded only after `await
  manager.open()`, so an editor that closed (or navigated to different
  content) while the companion was opening slipped past the lifecycle
  handlers and stranded an orphaned preview window. Re-validate after
  the open and destroy the companion when the editor is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015x434YzuoS1aXywkaS55St
The chromeless bridge posts `desktop-mode-focus-request` on every
pointerdown — but only on the outer admin document. Gutenberg renders
the post canvas inside a nested iframe (`editor-canvas`), and TinyMCE's
visual mode uses `#content_ifr`: clicks in there never reach the outer
document's listener, so clicking into the canvas of an unfocused editor
window was swallowed (only the toolbar/sidebar would activate it —
visible since the preview companion made iframe → iframe focus moves
routine).

Attach the same capture-phase escalation inside every same-origin
nested frame: swept at boot and on DOM mutations (Gutenberg mounts the
canvas asynchronously and re-creates it), re-hooked per document on
each frame load. WeakSets keep the sweep idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015x434YzuoS1aXywkaS55St

@epeicher epeicher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @AllTerrainDeveloper! I have tested this, and it works as expected. I can see the new Preview there. Changes also LGTM! 🚢

Image

AllTerrainDeveloper and others added 3 commits July 25, 2026 20:51
The nested-frame focus escalation installed a MutationObserver in
every chromeless iframe whose callback re-ran
`document.querySelectorAll( 'iframe' )` on each mutation batch. The
WeakSets made the hooking idempotent but not the sweep, so the
full-tree query fired regardless — an O(DOM) walk on Gutenberg's
typing path, which is exactly when the editor mutates hardest and
the editor-preview pairing is live.

Walk each record's `addedNodes` instead, matching the shape the
component-sniffer observer at the top of this file already uses. A
frame that was never inserted cannot need hooking, so the narrow
sweep is behaviour-preserving; the WeakSets still cover subtrees
that are moved rather than created.

Also stub `contentWindow` in the post-swap `reload()` test: jsdom
has no navigation, so the real `location.reload()` logged "Not
implemented" to the virtual console (it does not throw, so
`reload()`'s own catch never ran) and the noise landed in CI logs.
jsdom's `Location` rejects spies, hence the element-level stub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AllTerrainDeveloper
AllTerrainDeveloper merged commit cb03efe into trunk Jul 27, 2026
5 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the add/editor-preview-eye-button branch July 27, 2026 12:12
epeicher added a commit that referenced this pull request Jul 27, 2026
Resolves conflicts from the editor-preview (#398) and corner-radius
(#422) features by keeping trunk's new content with its version
stamps stripped, per the new AGENTS.md rule: new @SInCE tags in
window-links.php, editor-preview/*, types.ts, window/index.ts,
hooks.ts, and the new hooks-reference / javascript-reference /
bridge-protocol / api-index sections all land clean.

Also repairs three headings in bridge-protocol.md that the original
cleanup had accidentally glued to their following paragraph, and the
one cross-reference that pointed at a glued slug.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants