PluginProbe
Gutenberg / 23.5.3
Gutenberg v23.5.3
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / compat / wordpress-7.1 / collaboration.php

collaboration.php in Gutenberg 23.5.3, at lib/compat/wordpress-7.1/collaboration.php

477 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bootstraps collaborative editing.
4 *
5 * @package gutenberg
6 */
7
8 require_once __DIR__ . '/class-wp-sync-config.php';
9 if ( ! class_exists( 'WP_Sync_Post_Meta_Storage' ) ) {
10 require_once __DIR__ . '/interface-wp-sync-storage.php';
11 require_once __DIR__ . '/class-wp-sync-post-meta-storage.php';
12 require_once __DIR__ . '/class-wp-http-polling-sync-server.php';
13 }
14 require_once __DIR__ . '/class-wp-sync-save-server.php';
15
16 if ( ! function_exists( 'gutenberg_register_sync_storage_post_type' ) ) {
17 /**
18 * Registers the custom post type for sync storage.
19 */
20 function gutenberg_register_sync_storage_post_type() {
21 register_post_type(
22 'wp_sync_storage',
23 array(
24 'labels' => array(
25 'name' => __( 'Sync Updates', 'gutenberg' ),
26 'singular_name' => __( 'Sync Update', 'gutenberg' ),
27 ),
28 'public' => false,
29 'hierarchical' => false,
30 'capabilities' => array(
31 'read' => 'do_not_allow',
32 'read_private_posts' => 'do_not_allow',
33 'create_posts' => 'do_not_allow',
34 'publish_posts' => 'do_not_allow',
35 'edit_posts' => 'do_not_allow',
36 'edit_others_posts' => 'do_not_allow',
37 'edit_published_posts' => 'do_not_allow',
38 'delete_posts' => 'do_not_allow',
39 'delete_others_posts' => 'do_not_allow',
40 'delete_published_posts' => 'do_not_allow',
41 ),
42 'map_meta_cap' => false,
43 'publicly_queryable' => false,
44 'query_var' => false,
45 'rewrite' => false,
46 'show_in_menu' => false,
47 'show_in_rest' => false,
48 'show_ui' => false,
49 'supports' => array( 'custom-fields' ),
50 )
51 );
52 }
53 add_action( 'init', 'gutenberg_register_sync_storage_post_type' );
54 }
55
56 if ( ! function_exists( 'gutenberg_register_collaboration_rest_routes' ) ) {
57 /**
58 * Registers REST API routes for collaborative editing.
59 */
60 function gutenberg_register_collaboration_rest_routes(): void {
61 $sync_storage = new WP_Sync_Post_Meta_Storage();
62 $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage );
63 $sync_server->register_routes();
64
65 $sync_save_server = new WP_Sync_Save_Server();
66 $sync_save_server->register_routes();
67 }
68 add_action( 'rest_api_init', 'gutenberg_register_collaboration_rest_routes' );
69 }
70
71 if ( ! function_exists( 'wp_collaboration_register_meta' ) ) {
72 /**
73 * Registers post meta for persisting CRDT documents.
74 */
75 function gutenberg_rest_api_crdt_post_meta() {
76 // This string must match POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/core-data.
77 $persisted_crdt_post_meta_key = '_crdt_document';
78
79 register_meta(
80 'post',
81 $persisted_crdt_post_meta_key,
82 array(
83 'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool {
84 return user_can( $user_id, 'edit_post', $object_id );
85 },
86 /*
87 * Revisions must be disabled because we always want to preserve
88 * the latest persisted CRDT document, even when a revision is restored.
89 * This ensures that we can continue to apply updates to a shared document
90 * and peers can simply merge the restored revision like any other incoming
91 * update.
92 *
93 * If we want to persist CRDT documents alongside revisions in the
94 * future, we should do so in a separate meta key.
95 */
96 'revisions_enabled' => false,
97 'show_in_rest' => array(
98 'schema' => array(
99 'type' => 'string',
100 'context' => array( 'edit' ),
101 ),
102 ),
103 'single' => true,
104 'type' => 'string',
105 )
106 );
107 }
108 add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' );
109 }
110
111 if ( ! function_exists( 'wp_collaboration_inject_setting' ) ) {
112 /**
113 * Registers the real-time collaboration setting.
114 */
115 function gutenberg_register_real_time_collaboration_setting() {
116 $option_name = 'wp_collaboration_enabled';
117
118 register_setting(
119 'writing',
120 $option_name,
121 array(
122 'type' => 'boolean',
123 'description' => __( 'Enable Real-Time Collaboration', 'gutenberg' ),
124 'sanitize_callback' => 'rest_sanitize_boolean',
125 'default' => true,
126 'show_in_rest' => true,
127 )
128 );
129
130 add_settings_field(
131 $option_name,
132 __( 'Collaboration', 'gutenberg' ),
133 function () use ( $option_name ) {
134 $option_value = get_option( $option_name );
135
136 if ( wp_is_collaboration_allowed() ) :
137 ?>
138 <label for="wp_collaboration_enabled">
139 <input name="wp_collaboration_enabled" type="checkbox" id="wp_collaboration_enabled" value="1" <?php checked( '1', $option_value ); ?>/>
140 <?php _e( "Enable early access to real-time collaboration. Real-time collaboration may affect your website's performance.", 'gutenberg' ); ?>
141 </label>
142 <?php else : ?>
143 <div class="notice notice-warning inline">
144 <?php
145 printf(
146 /* translators: %s: Prefix "Note:". */
147 '<p>' . __( '%s Real-time collaboration has been disabled.', 'gutenberg' ) . '</p>',
148 '<strong>' . __( 'Note:', 'gutenberg' ) . '</strong>'
149 );
150 ?>
151 </div>
152 <?php
153 endif;
154 },
155 'writing'
156 );
157 }
158 add_action( 'admin_init', 'gutenberg_register_real_time_collaboration_setting' );
159 }
160
161 if ( ! function_exists( 'wp_is_collaboration_enabled' ) ) {
162 /**
163 * Determines whether real-time collaboration is enabled.
164 *
165 * If the WP_ALLOW_COLLABORATION constant is false,
166 * collaboration is always disabled regardless of the database option.
167 * Otherwise, falls back to the 'wp_collaboration_enabled' option.
168 *
169 * @since 7.0.0
170 *
171 * @return bool Whether real-time collaboration is enabled.
172 */
173 function wp_is_collaboration_enabled() {
174 return ( wp_is_collaboration_allowed() && (bool) get_option( 'wp_collaboration_enabled' ) );
175 }
176 }
177
178 if ( ! function_exists( 'wp_is_collaboration_allowed' ) ) {
179 /**
180 * Determines whether real-time collaboration is allowed.
181 *
182 * If the WP_ALLOW_COLLABORATION constant is false,
183 * collaboration is not allowed and cannot be enabled.
184 * The constant defaults to true, unless the WP_ALLOW_COLLABORATION
185 * environment variable is set to string "false".
186 *
187 * @since 7.0.0
188 *
189 * @return bool Whether real-time collaboration is allowed.
190 */
191 function wp_is_collaboration_allowed() {
192 if ( ! defined( 'WP_ALLOW_COLLABORATION' ) ) {
193 $env_value = getenv( 'WP_ALLOW_COLLABORATION' );
194 if ( false === $env_value ) {
195 // Environment variable is not defined, default to allowing collaboration.
196 define( 'WP_ALLOW_COLLABORATION', true );
197 } else {
198 /*
199 * Environment variable is defined, let's confirm it is actually set to
200 * "true" as it may still have a string value "false" – the preceeding
201 * `if` branch only tests for the boolean `false`.
202 */
203 define( 'WP_ALLOW_COLLABORATION', 'true' === $env_value );
204 }
205 }
206
207 return WP_ALLOW_COLLABORATION;
208 }
209 }
210
211 if ( ! function_exists( 'wp_is_post_type_collaboration_disabled' ) ) {
212 /**
213 * Determines whether real-time collaboration is disabled for a post type.
214 *
215 * @since 7.1.0
216 *
217 * @param string $post_type Post type name.
218 * @return bool Whether real-time collaboration is disabled for the post type.
219 */
220 function wp_is_post_type_collaboration_disabled( $post_type ) {
221 if ( ! post_type_exists( $post_type ) ) {
222 return true;
223 }
224
225 /**
226 * Filters whether real-time collaboration is disabled for a post type.
227 *
228 * @since 7.1.0
229 *
230 * @param bool $disabled Whether real-time collaboration is disabled for the post type.
231 * @param string $post_type Post type name.
232 */
233 return (bool) apply_filters( 'wp_is_post_type_collaboration_disabled', false, $post_type );
234 }
235 }
236
237 /**
238 * Injects the real-time collaboration setting into a global variable.
239 *
240 * @global string $pagenow The filename of the current screen.
241 */
242 function gutenberg_inject_real_time_collaboration_setting() {
243 global $pagenow;
244
245 if ( ! wp_is_collaboration_enabled() ) {
246 return;
247 }
248
249 // Disable real-time collaboration on the site editor.
250 $enabled = true;
251 if (
252 'site-editor.php' === $pagenow ||
253 ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'site-editor-v2' === $_GET['page'] )
254 ) {
255 $enabled = false;
256 }
257
258 $disabled_post_types = array_values(
259 array_filter(
260 get_post_types( array( 'show_in_rest' => true ) ),
261 'wp_is_post_type_collaboration_disabled'
262 )
263 );
264
265 wp_add_inline_script(
266 'wp-core-data',
267 'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';' .
268 'window._wpCollaborationDisabledPostTypes = ' . wp_json_encode( $disabled_post_types ) . ';',
269 'after'
270 );
271 }
272 add_action( 'admin_init', 'gutenberg_inject_real_time_collaboration_setting' );
273
274 /**
275 * Core adds an option with the default value, so we need to set the option to
276 * our intended default when the Gutenberg plugin is activated, provided
277 * collaboration is allowed.
278 */
279 function gutenberg_set_collaboration_option_on_activation() {
280 if ( wp_is_collaboration_allowed() ) {
281 update_option( 'wp_collaboration_enabled', '1' );
282 }
283 }
284 add_action( 'activate_' . plugin_basename( dirname( __DIR__, 3 ) . '/gutenberg.php' ), 'gutenberg_set_collaboration_option_on_activation' );
285
286 /**
287 * Modifies the post list UI and heartbeat responses for real-time collaboration.
288 *
289 * When RTC is enabled, hides the lock icon and user avatar, replaces the
290 * user-specific lock text with "Currently being edited", changes the "Edit"
291 * row action to "Join", and re-enables controls that core normally hides
292 * for locked posts (since collaborative editing is possible).
293 *
294 * @global string $pagenow The filename of the current screen.
295 */
296 function gutenberg_post_list_collaboration_ui() {
297 global $pagenow;
298
299 if ( ! wp_is_collaboration_enabled() ) {
300 return;
301 }
302
303 // Heartbeat filter applies globally (not just edit.php) since the
304 // heartbeat API can fire from any admin page.
305 add_filter( 'heartbeat_received', 'gutenberg_filter_locked_posts_heartbeat_for_rtc', 20 );
306
307 // CSS, JS, and row action overrides only apply on the posts list page.
308 if ( 'edit.php' !== $pagenow ) {
309 return;
310 }
311
312 add_action( 'admin_head', 'gutenberg_post_list_collaboration_styles' );
313 add_filter( 'gettext', 'gutenberg_filter_locked_post_text_for_rtc', 10, 3 );
314 add_filter( 'post_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 );
315 add_filter( 'page_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 );
316 }
317 add_action( 'admin_init', 'gutenberg_post_list_collaboration_ui' );
318
319 /**
320 * Filters the heartbeat response to remove user-specific lock information
321 * when real-time collaboration is enabled.
322 *
323 * WordPress core's wp_check_locked_posts() runs at priority 10 and populates
324 * the 'wp-check-locked-posts' key with user name, avatar, and text. This
325 * filter runs at priority 20 to replace that data with a generic message,
326 * preventing user-specific lock info from reaching the client.
327 *
328 * @param array $response The heartbeat response.
329 * @return array Modified heartbeat response.
330 */
331 function gutenberg_filter_locked_posts_heartbeat_for_rtc( $response ) {
332 if ( ! empty( $response['wp-check-locked-posts'] ) ) {
333 foreach ( $response['wp-check-locked-posts'] as $key => $lock_data ) {
334 $response['wp-check-locked-posts'][ $key ]['text'] = __( 'Currently being edited', 'gutenberg' );
335 unset( $response['wp-check-locked-posts'][ $key ]['avatar_src'] );
336 unset( $response['wp-check-locked-posts'][ $key ]['avatar_src_2x'] );
337 }
338 }
339
340 return $response;
341 }
342
343 /**
344 * Outputs CSS to hide the post lock icon and user avatar in the post list
345 * when real-time collaboration is enabled.
346 *
347 * Also re-enables checkboxes and row actions that WordPress core hides for
348 * locked posts, since collaborative editing means the post is not exclusively
349 * locked. Toggles "Edit" / "Join" action link text via the
350 * `.wp-collaborative-editing` class that the heartbeat already manages.
351 */
352 function gutenberg_post_list_collaboration_styles() {
353 ?>
354 <style type="text/css">
355 /*
356 * Hide the lock indicator icon in the checkbox column.
357 * WordPress core shows it via .wp-locked .locked-indicator { display: block },
358 * so we match that specificity to override it.
359 */
360 .wp-locked .locked-indicator {
361 display: none;
362 }
363 /* Hide the user avatar in the locked info area. */
364 .wp-locked .locked-info .locked-avatar {
365 display: none;
366 }
367 /*
368 * Re-enable controls that core hides for locked posts,
369 * since RTC allows collaborative editing.
370 * Must use `tr.wp-locked` to match core's specificity in
371 * list-tables.css and actually override its `display: none`.
372 */
373 tr.wp-locked .check-column label,
374 tr.wp-locked .check-column input[type="checkbox"] {
375 display: revert;
376 }
377 tr.wp-locked .row-actions .inline {
378 display: revert;
379 }
380 /*
381 * Toggle "Edit" / "Join" action link text based on lock state.
382 * The heartbeat adds/removes .wp-locked on locked rows. This
383 * CSS only runs when RTC is enabled, so .wp-locked here always
384 * means collaborative editing, not exclusive locking.
385 */
386 .join-action-text {
387 display: none;
388 }
389 .wp-locked .edit-action-text {
390 display: none;
391 }
392 .wp-locked .join-action-text {
393 display: inline;
394 }
395 </style>
396 <?php
397 }
398
399 /**
400 * Filters the translation of the lock text to replace user-specific
401 * "%s is currently editing" with a generic "Currently being edited"
402 * message on initial page render.
403 *
404 * WordPress core outputs this text server-side in WP_Posts_List_Table.
405 * Using a gettext filter replaces it before it reaches the browser,
406 * avoiding a flash of the original text.
407 *
408 * @param string $translation Translated text.
409 * @param string $text Original text to translate.
410 * @param string $domain Text domain.
411 * @return string Modified translation.
412 */
413 function gutenberg_filter_locked_post_text_for_rtc( $translation, $text, $domain ) {
414 if ( 'default' === $domain && '%s is currently editing' === $text ) {
415 return __( 'Currently being edited', 'gutenberg' );
416 }
417
418 return $translation;
419 }
420
421 /**
422 * Filters post row actions to render both "Edit" and "Join" link text
423 * when real-time collaboration is enabled.
424 *
425 * Both labels are always present in the markup; CSS toggles visibility
426 * based on the `.wp-collaborative-editing` class the heartbeat manages.
427 * This ensures the link text updates when the lock state changes without
428 * requiring a page reload.
429 *
430 * @param string[] $actions An array of row action links.
431 * @param WP_Post $post The post object.
432 * @return string[] Modified row action links.
433 */
434 function gutenberg_post_list_collaboration_row_actions( $actions, $post ) {
435 if ( ! isset( $actions['edit'] ) ) {
436 return $actions;
437 }
438
439 if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) {
440 return $actions;
441 }
442
443 $title = _draft_or_post_title( $post->ID );
444
445 /*
446 * Each state is rendered as `<span class="…-action-text"><a>…</a></span>`.
447 * The toggle classes sit on the outer <span> rather than the <a> so they
448 * fall outside core's responsive selector `.row-actions span a` at
449 * <=782px, which otherwise outranks our class selectors and (a) leaves
450 * both labels visible on unlocked rows and (b) forces `display: inline`
451 * on the visible Join link to misalign with sibling row actions. The
452 * visible label is still a direct text child of <a>, so core's mobile
453 * font-size rule
454 * .row-actions span { font-size: 0; }
455 * .row-actions span a { font-size: 13px; }
456 * still reaches it — that's the fix for the original "Edit invisible
457 * at 0px on mobile" regression. CSS in
458 * gutenberg_post_list_collaboration_styles() flips visibility on the
459 * outer spans based on the row's `wp-locked` class, which core's
460 * inline-edit-post.js maintains in response to heartbeat ticks.
461 */
462 $actions['edit'] = sprintf(
463 '<span class="edit-action-text"><a href="%1$s" aria-label="%2$s">%3$s</a></span>'
464 . '<span class="join-action-text"><a href="%1$s" aria-label="%4$s">%5$s</a></span>',
465 esc_url( get_edit_post_link( $post->ID ) ),
466 /* translators: %s: Post title. */
467 esc_attr( sprintf( __( 'Edit &#8220;%s&#8221;' ), $title ) ),
468 __( 'Edit' ),
469 /* translators: %s: Post title. */
470 esc_attr( sprintf( __( 'Join editing &#8220;%s&#8221;', 'gutenberg' ), $title ) ),
471 /* translators: Action link text for a singular post in the post list. Can be any type of post. */
472 _x( 'Join', 'post list', 'gutenberg' )
473 );
474
475 return $actions;
476 }
477