Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/js/_enqueues/admin/site-editor-post-lock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @file Site Editor post lock bridge.
*
* Wires the Site Editor (wp_template, wp_template_part) into the existing
* Heartbeat-based post lock workflow. The bulk of the implementation lives in
* core: wp_refresh_post_lock() handles the server side, and the
* `#post-lock-dialog` markup is rendered by _admin_notice_post_locked() via
* site-editor.php. This file is the JS glue that:
*
* - Tracks the currently edited template entity through the core/edit-site
* data store so client-side navigation between templates is reflected in
* the Heartbeat payload.
* - Emits the same `wp-refresh-post-lock` payload that
* wp-admin/js/post.js sends from the classic editor.
* - Reveals the server-rendered lock dialog on a `lock_error` tick and
* stores the refreshed lock token on a `new_lock` tick.
*
* @output wp-admin/js/site-editor-post-lock.js
*/

/* global wp */

( function ( $, settings ) {
'use strict';

if ( ! settings || ! settings.enabled ) {
return;
}

var initialPostId = parseInt( settings.postId, 10 ) || 0;
var activePostId = initialPostId;
var activeLock = String( settings.lock || '' );

/**
* Subscribe to the core/edit-site data store so the active post ID is
* always kept in sync when the user navigates between templates without
* a full page reload.
*/
if ( wp && wp.data && typeof wp.data.subscribe === 'function' ) {
wp.data.subscribe( function () {
var store = wp.data.select( 'core/edit-site' );
if ( ! store ) {
return;
}

var nextType = store.getEditedPostType();
var nextId = store.getEditedPostId();

if (
( 'wp_template' === nextType || 'wp_template_part' === nextType ) &&
'number' === typeof nextId &&
nextId !== activePostId
) {
activePostId = nextId;
/*
* The new entity will receive its own lock token via the
* next Heartbeat tick.
*/
activeLock = '';
}
} );
}

$( function () {
var $dialog = $( '#post-lock-dialog' );
if ( $dialog.length && settings.takeOverUrl ) {
$dialog.find( '.button-primary.wp-tab-last' ).attr( 'href', settings.takeOverUrl );
}
} );

$( document )
/*
* Send the lock payload on every Heartbeat tick when we have a
* resolved template post. Mirrors `heartbeat-send.refresh-lock`
* in wp-admin/js/post.js.
*/
.on( 'heartbeat-send.refresh-site-editor-post-lock', function ( e, data ) {
if ( ! activePostId ) {
return;
}

var send = { post_id: activePostId };

if ( activeLock ) {
send.lock = activeLock;
}

data['wp-refresh-post-lock'] = send;
} )
/*
* Handle the response: surface the server-rendered dialog on
* `lock_error`, or remember the refreshed lock token on
* `new_lock`.
*/
.on( 'heartbeat-tick.refresh-site-editor-post-lock', function ( e, data ) {
if ( ! data['wp-refresh-post-lock'] ) {
return;
}

var received = data['wp-refresh-post-lock'];

if ( received.lock_error ) {
var $wrap = $( '#post-lock-dialog' );

if ( $wrap.length && ! $wrap.is( ':visible' ) ) {
if ( received.lock_error.avatar_src ) {
var $avatar = $( '<img />', {
'class': 'avatar avatar-64 photo',
width: 64,
height: 64,
alt: '',
src: received.lock_error.avatar_src,
srcset: received.lock_error.avatar_src_2x ?
received.lock_error.avatar_src_2x + ' 2x' :
undefined
} );

$wrap.find( 'div.post-locked-avatar' ).empty().append( $avatar );
}

$wrap.removeClass( 'hidden' )
.show()
.find( '.currently-editing' ).text( received.lock_error.text );

$wrap.find( '.wp-tab-first' ).trigger( 'focus' );
}
} else if ( received.new_lock ) {
activeLock = received.new_lock;
}
} );

}( jQuery, window.wpSiteEditorPostLockL10n ) );
4 changes: 4 additions & 0 deletions src/wp-admin/includes/admin-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@
add_filter( 'heartbeat_received', 'wp_refresh_post_lock', 10, 3 );
add_filter( 'heartbeat_received', 'heartbeat_autosave', 500, 2 );

// Site Editor post lock acquisition during REST edit-context reads.
add_filter( 'rest_prepare_wp_template', 'wp_set_site_editor_post_lock_on_rest_prepare', 10, 3 );
add_filter( 'rest_prepare_wp_template_part', 'wp_set_site_editor_post_lock_on_rest_prepare', 10, 3 );

add_filter( 'wp_refresh_nonces', 'wp_refresh_post_nonces', 10, 3 );
add_filter( 'wp_refresh_nonces', 'wp_refresh_metabox_loader_nonces', 10, 2 );
add_filter( 'wp_refresh_nonces', 'wp_refresh_heartbeat_nonces' );
Expand Down
51 changes: 51 additions & 0 deletions src/wp-admin/includes/post.php
Original file line number Diff line number Diff line change
Expand Up @@ -1778,6 +1778,57 @@ function wp_set_post_lock( $post ) {
return array( $now, $user_id );
}

/**
* Acquires a Site Editor post lock during REST API edit-context responses.
*
* The classic editor calls {@see wp_set_post_lock()} when an edit screen renders,
* but Site Editor templates ({@see wp_template}, {@see wp_template_part}) are
* loaded over the REST API instead of through `post.php`. This function bridges
* that gap so the existing Heartbeat-based post locking workflow can be reused
* for the Site Editor.
*
* Hooked to `rest_prepare_wp_template` and `rest_prepare_wp_template_part`. The
* lock is only acquired when the request `context` is `edit` and the
* `wp_apply_site_editor_post_lock` filter does not opt out (for example, when a
* real-time collaboration plugin is active).
*
* @since 7.1.0
*
* @param WP_REST_Response $response The response object.
* @param WP_Post $post The post object being prepared.
* @param WP_REST_Request $request The current request.
* @return WP_REST_Response The unchanged response object.
*/
function wp_set_site_editor_post_lock_on_rest_prepare( $response, $post, $request ) {
if ( 'edit' !== $request['context'] ) {
return $response;
}

if ( empty( $post ) || empty( $post->ID ) ) {
return $response;
}

/**
* Filters whether the Site Editor post lock should be applied to a template.
*
* Returning false disables both lock acquisition during REST edit-context
* reads and the "Take over" flow on `site-editor.php`. Real-time
* collaboration plugins can short-circuit the entire lock pipeline here.
*
* @since 7.1.0
*
* @param bool $apply Whether to apply the Site Editor post lock. Default true.
* @param WP_Post $post The template post being prepared.
*/
if ( ! apply_filters( 'wp_apply_site_editor_post_lock', true, $post ) ) {
return $response;
}

wp_set_post_lock( $post );

return $response;
}

/**
* Outputs the HTML for the notice to say that someone else is editing or has taken over editing of this post.
*
Expand Down
111 changes: 111 additions & 0 deletions src/wp-admin/site-editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,86 @@ function _wp_get_site_editor_redirection_url() {
exit;
}

/*
* Resolve the template post being edited, if any.
*
* Site Editor URLs use the canonical `?p=/{post_type}/{post_id}` form once the
* redirection block above has run. Theme-supplied templates that have not been
* persisted yet (no numeric ID) are intentionally not resolved here; locking
* starts at the point a real WP_Post exists.
*/
$site_editor_post = null;
$site_editor_post_candidate = null;

if ( ! empty( $_GET['postId'] ) && is_numeric( $_GET['postId'] ) ) {
$site_editor_post_candidate = get_post( (int) $_GET['postId'] );
} elseif ( isset( $_GET['p'] ) && preg_match( '#^/(wp_template|wp_template_part)/(\d+)$#', $_GET['p'], $site_editor_post_matches ) ) {
$site_editor_post_candidate = get_post( (int) $site_editor_post_matches[2] );
}

if (
$site_editor_post_candidate instanceof WP_Post &&
in_array( $site_editor_post_candidate->post_type, array( 'wp_template', 'wp_template_part' ), true )
) {
$site_editor_post = $site_editor_post_candidate;
}

unset( $site_editor_post_candidate, $site_editor_post_matches );

/*
* Determine whether locking applies to the resolved template. Computed once so
* the take-over handler, lock acquisition, and dialog hook below all share a
* single filter dispatch.
*/
$site_editor_post_lock_enabled = false;

if ( $site_editor_post ) {
/** This filter is documented in wp-admin/includes/post.php */
$site_editor_post_lock_enabled = (bool) apply_filters( 'wp_apply_site_editor_post_lock', true, $site_editor_post );
}

// Handle "Take over" requests from the Site Editor post lock dialog.
if ( $site_editor_post && ! empty( $_GET['get-post-lock'] ) ) {
check_admin_referer( 'lock-post_' . $site_editor_post->ID );

if ( $site_editor_post_lock_enabled ) {
wp_set_post_lock( $site_editor_post->ID );
}

wp_safe_redirect( remove_query_arg( array( 'get-post-lock', '_wpnonce' ) ) );
exit;
}

/*
* Acquire (or refresh) the post lock for the current user when no other user
* holds it. Mirrors the wp-admin/post.php:191-192 pattern.
*/
$site_editor_active_post_lock = null;

if ( $site_editor_post_lock_enabled && ! wp_check_post_lock( $site_editor_post->ID ) ) {
$site_editor_active_post_lock = wp_set_post_lock( $site_editor_post );
}

/*
* Render the shared post lock dialog markup in the Site Editor footer.
*
* The classic editor hooks _admin_notice_post_locked() from
* wp-admin/edit-form-advanced.php; templates never go through that path, so
* the same hook is registered here with the resolved template post swapped
* into $GLOBALS['post'] for the duration of the callback.
*/
if ( $site_editor_post && $site_editor_post_lock_enabled ) {
add_action(
'admin_footer',
static function () use ( $site_editor_post ) {
$previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
$GLOBALS['post'] = $site_editor_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restored below.
_admin_notice_post_locked();
$GLOBALS['post'] = $previous_post;
}
);
}

// Used in the HTML title tag.
$title = _x( 'Editor', 'site editor title tag' );
$parent_file = 'themes.php';
Expand Down Expand Up @@ -303,6 +383,37 @@ static function ( $classes ) {
wp_enqueue_style( 'wp-format-library' );
wp_enqueue_media();

// Site Editor post lock bridge.
if ( $site_editor_post && $site_editor_post_lock_enabled ) {
wp_enqueue_script( 'heartbeat' );
wp_enqueue_script( 'site-editor-post-lock' );

wp_localize_script(
'site-editor-post-lock',
'wpSiteEditorPostLockL10n',
array(
'enabled' => true,
'postId' => $site_editor_post->ID,
'postType' => $site_editor_post->post_type,
'lock' => is_array( $site_editor_active_post_lock ) ? implode( ':', $site_editor_active_post_lock ) : '',
'takeOverUrl' => add_query_arg(
'get-post-lock',
'1',
wp_nonce_url(
add_query_arg(
array(
'p' => '/' . $site_editor_post->post_type . '/' . $site_editor_post->ID,
'canvas' => 'edit',
),
admin_url( 'site-editor.php' )
),
'lock-post_' . $site_editor_post->ID
)
),
)
);
}

if (
current_theme_supports( 'wp-block-styles' ) &&
( ! is_array( $editor_styles ) || count( $editor_styles ) === 0 )
Expand Down
3 changes: 3 additions & 0 deletions src/wp-includes/script-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -1449,6 +1449,9 @@ function wp_default_scripts( $scripts ) {
$scripts->add( 'post', "/wp-admin/js/post$suffix.js", array( 'suggest', 'wp-lists', 'postbox', 'tags-box', 'underscore', 'word-count', 'wp-a11y', 'wp-sanitize', 'clipboard' ), false, 1 );
$scripts->set_translations( 'post' );

$scripts->add( 'site-editor-post-lock', "/wp-admin/js/site-editor-post-lock$suffix.js", array( 'jquery', 'heartbeat', 'wp-data', 'wp-i18n' ), false, 1 );
$scripts->set_translations( 'site-editor-post-lock' );

$scripts->add( 'editor-expand', "/wp-admin/js/editor-expand$suffix.js", array( 'jquery', 'underscore' ), false, 1 );

$scripts->add( 'link', "/wp-admin/js/link$suffix.js", array( 'wp-lists', 'postbox' ), false, 1 );
Expand Down
Loading
Loading