PluginProbe
Gutenberg / 23.4.0
Gutenberg v23.4.0
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.4.0, at lib/compat/wordpress-7.1/collaboration.php

439 lines 15.0 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 /**
212 * Injects the real-time collaboration setting into a global variable.
213 *
214 * @global string $pagenow The filename of the current screen.
215 */
216 function gutenberg_inject_real_time_collaboration_setting() {
217 global $pagenow;
218
219 if ( ! wp_is_collaboration_enabled() ) {
220 return;
221 }
222
223 // Disable real-time collaboration on the site editor.
224 $enabled = true;
225 if (
226 'site-editor.php' === $pagenow ||
227 ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'site-editor-v2' === $_GET['page'] )
228 ) {
229 $enabled = false;
230 }
231
232 wp_add_inline_script(
233 'wp-core-data',
234 'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';',
235 'after'
236 );
237 }
238 add_action( 'admin_init', 'gutenberg_inject_real_time_collaboration_setting' );
239
240 /**
241 * Core adds an option with the default value, so we need to set the option to
242 * our intended default when the Gutenberg plugin is activated, provided
243 * collaboration is allowed.
244 */
245 function gutenberg_set_collaboration_option_on_activation() {
246 if ( wp_is_collaboration_allowed() ) {
247 update_option( 'wp_collaboration_enabled', '1' );
248 }
249 }
250 add_action( 'activate_' . plugin_basename( dirname( __DIR__, 3 ) . '/gutenberg.php' ), 'gutenberg_set_collaboration_option_on_activation' );
251
252 /**
253 * Modifies the post list UI and heartbeat responses for real-time collaboration.
254 *
255 * When RTC is enabled, hides the lock icon and user avatar, replaces the
256 * user-specific lock text with "Currently being edited", changes the "Edit"
257 * row action to "Join", and re-enables controls that core normally hides
258 * for locked posts (since collaborative editing is possible).
259 *
260 * @global string $pagenow The filename of the current screen.
261 */
262 function gutenberg_post_list_collaboration_ui() {
263 global $pagenow;
264
265 if ( ! wp_is_collaboration_enabled() ) {
266 return;
267 }
268
269 // Heartbeat filter applies globally (not just edit.php) since the
270 // heartbeat API can fire from any admin page.
271 add_filter( 'heartbeat_received', 'gutenberg_filter_locked_posts_heartbeat_for_rtc', 20 );
272
273 // CSS, JS, and row action overrides only apply on the posts list page.
274 if ( 'edit.php' !== $pagenow ) {
275 return;
276 }
277
278 add_action( 'admin_head', 'gutenberg_post_list_collaboration_styles' );
279 add_filter( 'gettext', 'gutenberg_filter_locked_post_text_for_rtc', 10, 3 );
280 add_filter( 'post_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 );
281 add_filter( 'page_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 );
282 }
283 add_action( 'admin_init', 'gutenberg_post_list_collaboration_ui' );
284
285 /**
286 * Filters the heartbeat response to remove user-specific lock information
287 * when real-time collaboration is enabled.
288 *
289 * WordPress core's wp_check_locked_posts() runs at priority 10 and populates
290 * the 'wp-check-locked-posts' key with user name, avatar, and text. This
291 * filter runs at priority 20 to replace that data with a generic message,
292 * preventing user-specific lock info from reaching the client.
293 *
294 * @param array $response The heartbeat response.
295 * @return array Modified heartbeat response.
296 */
297 function gutenberg_filter_locked_posts_heartbeat_for_rtc( $response ) {
298 if ( ! empty( $response['wp-check-locked-posts'] ) ) {
299 foreach ( $response['wp-check-locked-posts'] as $key => $lock_data ) {
300 $response['wp-check-locked-posts'][ $key ]['text'] = __( 'Currently being edited', 'gutenberg' );
301 unset( $response['wp-check-locked-posts'][ $key ]['avatar_src'] );
302 unset( $response['wp-check-locked-posts'][ $key ]['avatar_src_2x'] );
303 }
304 }
305
306 return $response;
307 }
308
309 /**
310 * Outputs CSS to hide the post lock icon and user avatar in the post list
311 * when real-time collaboration is enabled.
312 *
313 * Also re-enables checkboxes and row actions that WordPress core hides for
314 * locked posts, since collaborative editing means the post is not exclusively
315 * locked. Toggles "Edit" / "Join" action link text via the
316 * `.wp-collaborative-editing` class that the heartbeat already manages.
317 */
318 function gutenberg_post_list_collaboration_styles() {
319 ?>
320 <style type="text/css">
321 /*
322 * Hide the lock indicator icon in the checkbox column.
323 * WordPress core shows it via .wp-locked .locked-indicator { display: block },
324 * so we match that specificity to override it.
325 */
326 .wp-locked .locked-indicator {
327 display: none;
328 }
329 /* Hide the user avatar in the locked info area. */
330 .wp-locked .locked-info .locked-avatar {
331 display: none;
332 }
333 /*
334 * Re-enable controls that core hides for locked posts,
335 * since RTC allows collaborative editing.
336 * Must use `tr.wp-locked` to match core's specificity in
337 * list-tables.css and actually override its `display: none`.
338 */
339 tr.wp-locked .check-column label,
340 tr.wp-locked .check-column input[type="checkbox"] {
341 display: revert;
342 }
343 tr.wp-locked .row-actions .inline {
344 display: revert;
345 }
346 /*
347 * Toggle "Edit" / "Join" action link text based on lock state.
348 * The heartbeat adds/removes .wp-locked on locked rows. This
349 * CSS only runs when RTC is enabled, so .wp-locked here always
350 * means collaborative editing, not exclusive locking.
351 */
352 .join-action-text {
353 display: none;
354 }
355 .wp-locked .edit-action-text {
356 display: none;
357 }
358 .wp-locked .join-action-text {
359 display: inline;
360 }
361 </style>
362 <?php
363 }
364
365 /**
366 * Filters the translation of the lock text to replace user-specific
367 * "%s is currently editing" with a generic "Currently being edited"
368 * message on initial page render.
369 *
370 * WordPress core outputs this text server-side in WP_Posts_List_Table.
371 * Using a gettext filter replaces it before it reaches the browser,
372 * avoiding a flash of the original text.
373 *
374 * @param string $translation Translated text.
375 * @param string $text Original text to translate.
376 * @param string $domain Text domain.
377 * @return string Modified translation.
378 */
379 function gutenberg_filter_locked_post_text_for_rtc( $translation, $text, $domain ) {
380 if ( 'default' === $domain && '%s is currently editing' === $text ) {
381 return __( 'Currently being edited', 'gutenberg' );
382 }
383
384 return $translation;
385 }
386
387 /**
388 * Filters post row actions to render both "Edit" and "Join" link text
389 * when real-time collaboration is enabled.
390 *
391 * Both labels are always present in the markup; CSS toggles visibility
392 * based on the `.wp-collaborative-editing` class the heartbeat manages.
393 * This ensures the link text updates when the lock state changes without
394 * requiring a page reload.
395 *
396 * @param string[] $actions An array of row action links.
397 * @param WP_Post $post The post object.
398 * @return string[] Modified row action links.
399 */
400 function gutenberg_post_list_collaboration_row_actions( $actions, $post ) {
401 if ( ! isset( $actions['edit'] ) ) {
402 return $actions;
403 }
404
405 $title = _draft_or_post_title( $post->ID );
406
407 /*
408 * Each state is rendered as `<span class="…-action-text"><a>…</a></span>`.
409 * The toggle classes sit on the outer <span> rather than the <a> so they
410 * fall outside core's responsive selector `.row-actions span a` at
411 * <=782px, which otherwise outranks our class selectors and (a) leaves
412 * both labels visible on unlocked rows and (b) forces `display: inline`
413 * on the visible Join link to misalign with sibling row actions. The
414 * visible label is still a direct text child of <a>, so core's mobile
415 * font-size rule
416 * .row-actions span { font-size: 0; }
417 * .row-actions span a { font-size: 13px; }
418 * still reaches it — that's the fix for the original "Edit invisible
419 * at 0px on mobile" regression. CSS in
420 * gutenberg_post_list_collaboration_styles() flips visibility on the
421 * outer spans based on the row's `wp-locked` class, which core's
422 * inline-edit-post.js maintains in response to heartbeat ticks.
423 */
424 $actions['edit'] = sprintf(
425 '<span class="edit-action-text"><a href="%1$s" aria-label="%2$s">%3$s</a></span>'
426 . '<span class="join-action-text"><a href="%1$s" aria-label="%4$s">%5$s</a></span>',
427 esc_url( get_edit_post_link( $post->ID ) ),
428 /* translators: %s: Post title. */
429 esc_attr( sprintf( __( 'Edit &#8220;%s&#8221;' ), $title ) ),
430 __( 'Edit' ),
431 /* translators: %s: Post title. */
432 esc_attr( sprintf( __( 'Join editing &#8220;%s&#8221;', 'gutenberg' ), $title ) ),
433 /* translators: Action link text for a singular post in the post list. Can be any type of post. */
434 _x( 'Join', 'post list', 'gutenberg' )
435 );
436
437 return $actions;
438 }
439