Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.30% covered (warning)
87.30%
715 / 819
61.90% covered (warning)
61.90%
13 / 21
CRAP
n/a
0 / 0
wpcom_write_asset_url
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
wpcom_write_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_write_wpcom_blog_id
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
wpcom_write_resolve_back_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
wpcom_write_get_editor_strings
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
1
wpcom_write_prompt_block_editor_url
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
12
wpcom_write_divert_prompt_to_block_editor
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
8.83
wpcom_write_remove_admin_notices
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
wpcom_write_convert_video_embeds
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
8
wpcom_write_allowed_block_attrs
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
wpcom_write_detect_unsupported_content
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
wpcom_write_is_transparent_background
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
wpcom_write_has_unsupported_color_class
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
wpcom_write_has_unsupported_mark
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
wpcom_write_has_unsupported_blocks
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
25.33
wpcom_write_alignment_classes_to_inline
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
wpcom_write_inline_color_marks_to_spans
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
wpcom_write_prompt_quote_markup
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
wpcom_write_get_recent_drafts
51.52% covered (warning)
51.52%
17 / 33
0.00% covered (danger)
0.00%
0 / 1
12.58
wpcom_write_render_admin_page
85.79% covered (warning)
85.79%
169 / 197
0.00% covered (danger)
0.00%
0 / 1
54.62
wpcom_write_template
92.97% covered (success)
92.97%
238 / 256
0.00% covered (danger)
0.00%
0 / 1
18.11
1<?php
2/**
3 * Write — A distraction-free front-end writing experience for WordPress.com.
4 *
5 * Based on Jamie Marsland's Write plugin (https://github.com/jamiemarsland/Write).
6 * Registers a wp-admin page that serves a clean, full-screen writing surface.
7 * Posts are saved as proper Gutenberg block markup via the REST API.
8 *
9 * @package automattic/jetpack-mu-wpcom
10 */
11
12if ( ! defined( 'ABSPATH' ) ) {
13    exit;
14}
15
16use Automattic\Jetpack\Connection\Manager as Connection_Manager;
17use Automattic\Jetpack\Jetpack_Mu_Wpcom\Common;
18use Automattic\Jetpack\Status\Host;
19
20if ( ! defined( 'WPCOM_WRITE_VERSION' ) ) {
21    // Use file modification time to bust CDN caches when files change.
22    define( 'WPCOM_WRITE_VERSION', (string) max( filemtime( __DIR__ . '/view.js' ), filemtime( __DIR__ . '/style.css' ), filemtime( __DIR__ . '/undo-history.js' ), filemtime( __DIR__ . '/image-format.js' ), filemtime( __DIR__ . '/text-helpers.js' ), filemtime( __DIR__ . '/quote-editing.js' ), filemtime( __DIR__ . '/post-publish-checklist.js' ), filemtime( __DIR__ . '/post-publish-checklist.css' ), filemtime( __DIR__ . '/post-publish-survey.js' ), filemtime( __DIR__ . '/post-publish-survey.css' ) ) );
23}
24
25if ( ! defined( 'WPCOM_WRITE_BLOCK_EDITOR_PREFERRED_COOKIE' ) ) {
26    // Marks a browser as having left Write for the Block editor. See view.js's
27    // markBlockEditorPreferred() for who writes it and who else reads it.
28    define( 'WPCOM_WRITE_BLOCK_EDITOR_PREFERRED_COOKIE', 'wpcom-write-block-editor-preferred' );
29}
30
31// Inline SVG icons used by the top bar and the formatting toolbar.
32require_once __DIR__ . '/icons.php';
33
34// Post-publish next-steps checklist, shown on the published post after a
35// Write-editor publish on a Coming Soon site.
36require_once __DIR__ . '/post-publish-checklist.php';
37
38// Email-verification launch gate backing the checklist's inline confirm-email step.
39require_once __DIR__ . '/email-verification.php';
40
41// One-question survey shown on the published post after a writer's first Write publish.
42require_once __DIR__ . '/post-publish-survey.php';
43
44/**
45 * Get the URL for a Write feature asset file.
46 *
47 * Uses plugins_url() when loaded via a plugin (production). Falls back to
48 * a computed URL for local development where plugins_url() can't resolve
49 * the monorepo path.
50 *
51 * @param string $file The asset filename (e.g. 'view.js', 'style.css').
52 * @return string The full URL to the asset.
53 */
54function wpcom_write_asset_url( $file ) {
55    $url = plugins_url( $file, __FILE__ );
56
57    // In local dev (Docker), __FILE__ resolves to an absolute monorepo path that
58    // plugins_url() can't map to a web-accessible URL. Detect this and fall back
59    // to the known plugin symlink path.
60    if ( strpos( $url, '/plugins/usr/' ) !== false || strpos( $url, '/plugins/Users/' ) !== false ) {
61        $url = content_url( 'plugins/jetpack-mu-wpcom-plugin/jetpack_vendor/automattic/jetpack-mu-wpcom/src/features/write/' . $file );
62    }
63
64    return $url;
65}
66
67/**
68 * Get the URL to the Write editor.
69 *
70 * @return string The admin URL for the Write page.
71 */
72function wpcom_write_url() {
73    return admin_url( 'admin.php?page=write' );
74}
75
76/**
77 * The wpcom blog ID, which Atomic's local `get_current_blog_id()` is not.
78 *
79 * @return int Blog ID, or 0 when the site has no wpcom identity.
80 */
81function wpcom_write_wpcom_blog_id() {
82    if ( ! class_exists( Connection_Manager::class ) ) {
83        return 0;
84    }
85
86    return (int) Connection_Manager::get_site_id( true );
87}
88
89/**
90 * Resolve the editor's back/close destination from the source the user arrived from.
91 *
92 * Maps a short allowlist of known source tokens to known internal destinations.
93 * Anything not in the allowlist (including an empty or inferred source) falls back
94 * to the default destination — the site dashboard — so behavior is unchanged.
95 *
96 * This is the single lookup point for back-button destinations: never echo an
97 * arbitrary return URL from the query string, only map to vetted destinations.
98 *
99 * @param string $source Sanitized source token (see wpcom_write_render_admin_page()).
100 * @return string The destination URL for the back/close button.
101 */
102function wpcom_write_resolve_back_url( $source ) {
103    // The prompt cards send a per-surface token rather than a bare
104    // `writing_prompt`: that one comes from the wp-admin Daily Writing Prompt
105    // widget, which belongs back on the dashboard the default below already gives.
106    if ( 'writing_prompt_home' === $source ) {
107        // A bare /home resolves to whichever landing page the account prefers,
108        // which need not be this site, so the id has to be in the URL.
109        $blog_id = wpcom_write_wpcom_blog_id();
110        return $blog_id ? 'https://wordpress.com/home/' . $blog_id : admin_url();
111    }
112
113    $destinations = array(
114        'reader'                => 'https://wordpress.com/reader',
115        'writing_prompt_reader' => 'https://wordpress.com/reader',
116    );
117
118    return $destinations[ $source ] ?? admin_url();
119}
120
121/**
122 * Translated UI strings consumed by view.js as `window.wpcomWriteStrings`.
123 *
124 * Exposed as a helper so callers that render the Write editor outside the
125 * wp-admin page lifecycle (and therefore never hit the admin_enqueue_scripts
126 * hook below) can print the same strings without duplicating the list.
127 *
128 * @return array<string, string> Map of i18n key -> translated string.
129 */
130function wpcom_write_get_editor_strings() {
131    return array(
132        'caption'              => __( 'Caption', 'jetpack-mu-wpcom' ),
133        'editImage'            => __( 'Edit image', 'jetpack-mu-wpcom' ),
134        'writeCaption'         => __( 'Write a caption...', 'jetpack-mu-wpcom' ),
135        // translators: %s is the error message from the upload failure.
136        'uploadFailed'         => __( 'Upload failed: %s', 'jetpack-mu-wpcom' ),
137        'uploadingImage'       => __( 'Uploading image…', 'jetpack-mu-wpcom' ),
138        'libraryLoading'       => __( 'Loading your library…', 'jetpack-mu-wpcom' ),
139        'libraryEmpty'         => __( 'No images in your library yet.', 'jetpack-mu-wpcom' ),
140        'libraryNoResults'     => __( 'No matching images.', 'jetpack-mu-wpcom' ),
141        'libraryLoadFailed'    => __( "Couldn't load your library.", 'jetpack-mu-wpcom' ),
142        // translators: %s is the alt text or filename of the selected library image.
143        'librarySelected'      => __( 'Selected %s', 'jetpack-mu-wpcom' ),
144        'invalidVideoUrl'      => __( 'Please paste a valid YouTube or Vimeo URL', 'jetpack-mu-wpcom' ),
145        'pleaseAddTitle'       => __( 'Please add a title', 'jetpack-mu-wpcom' ),
146        'pleaseWriteSomething' => __( 'Please write something', 'jetpack-mu-wpcom' ),
147        'savingDraft'          => __( 'Saving draft...', 'jetpack-mu-wpcom' ),
148        'updating'             => __( 'Updating...', 'jetpack-mu-wpcom' ),
149        'publishing'           => __( 'Publishing...', 'jetpack-mu-wpcom' ),
150        'updated'              => __( 'Updated!', 'jetpack-mu-wpcom' ),
151        'published'            => __( 'Published!', 'jetpack-mu-wpcom' ),
152        'draftSaved'           => __( 'Draft saved', 'jetpack-mu-wpcom' ),
153        'draftAutosaved'       => __( 'Draft saved', 'jetpack-mu-wpcom' ),
154        // translators: %s is the error message.
155        'error'                => __( 'Error: %s', 'jetpack-mu-wpcom' ),
156        'couldNotSave'         => __( 'Could not save. Please try again.', 'jetpack-mu-wpcom' ),
157        'saveTimedOut'         => __( 'Saving timed out. Please check your connection and try again.', 'jetpack-mu-wpcom' ),
158        'normal'               => __( 'Normal', 'jetpack-mu-wpcom' ),
159        'heading2'             => __( 'Heading 2', 'jetpack-mu-wpcom' ),
160        'heading3'             => __( 'Heading 3', 'jetpack-mu-wpcom' ),
161        'preview'              => __( 'Preview', 'jetpack-mu-wpcom' ),
162        // translators: %s is a comma-separated list of category names, e.g. "Travel, Food".
163        'writingIn'            => __( 'Writing in %s', 'jetpack-mu-wpcom' ),
164        'untitled'             => __( 'Untitled', 'jetpack-mu-wpcom' ),
165        'addCitation'          => __( 'Add citation…', 'jetpack-mu-wpcom' ),
166        'citation'             => __( 'Citation', 'jetpack-mu-wpcom' ),
167        'postNotFound'         => __( 'Post not found. Check the URL or ID and try again.', 'jetpack-mu-wpcom' ),
168        'postNoPermission'     => __( 'You don\'t have permission to edit this post.', 'jetpack-mu-wpcom' ),
169        // Labels used only when the editor is rendered for a logged-out
170        // visitor (window.wpcomWriteIsAnon). "WordPress.com" is a product
171        // mark and stays untranslated; only the feature name is localised.
172        'anonBrand'            => 'WordPress.com · ' . _x( 'Write', 'editor name in the anonymous brand label', 'jetpack-mu-wpcom' ),
173        'anonStatus'           => __( 'Not signed in', 'jetpack-mu-wpcom' ),
174    );
175}
176
177/**
178 * Register the script module on init.
179 */
180add_action(
181    'init',
182    function () {
183        wp_register_script_module(
184            'wpcom-write/undo-history',
185            wpcom_write_asset_url( 'undo-history.js' ),
186            array(),
187            WPCOM_WRITE_VERSION
188        );
189        wp_register_script_module(
190            'wpcom-write/image-format',
191            wpcom_write_asset_url( 'image-format.js' ),
192            array(),
193            WPCOM_WRITE_VERSION
194        );
195        wp_register_script_module(
196            'wpcom-write/text-helpers',
197            wpcom_write_asset_url( 'text-helpers.js' ),
198            array(),
199            WPCOM_WRITE_VERSION
200        );
201        wp_register_script_module(
202            'wpcom-write/quote-editing',
203            wpcom_write_asset_url( 'quote-editing.js' ),
204            array(),
205            WPCOM_WRITE_VERSION
206        );
207        wp_register_script_module(
208            'wpcom-write/view',
209            wpcom_write_asset_url( 'view.js' ),
210            array(
211                '@wordpress/interactivity',
212                'wpcom-write/undo-history',
213                'wpcom-write/image-format',
214                'wpcom-write/text-helpers',
215                'wpcom-write/quote-editing',
216            ),
217            WPCOM_WRITE_VERSION
218        );
219    }
220);
221
222// Hidden submenu pages (empty parent slug) are not found by get_admin_page_title(),
223// so the browser tab shows only the site name. Fix via the admin_title filter.
224add_filter(
225    'admin_title',
226    function ( $admin_title, $title ) {
227        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page routing.
228        if ( empty( $title ) && isset( $_GET['page'] ) && 'write' === $_GET['page'] ) {
229            return __( 'Write editor', 'jetpack-mu-wpcom' ) . $admin_title;
230        }
231        return $admin_title;
232    },
233    10,
234    2
235);
236
237/**
238 * Register the Write admin page.
239 *
240 * Uses an empty parent to create a hidden page (no menu entry) — access is
241 * via the admin bar "Write" link. Renders inside wp-admin's normal page
242 * lifecycle so that wp.apiFetch and its middleware are fully configured.
243 */
244add_action(
245    'admin_menu',
246    function () {
247        add_submenu_page(
248            '', // Hidden — no parent menu.
249            __( 'Write', 'jetpack-mu-wpcom' ),
250            __( 'Write', 'jetpack-mu-wpcom' ),
251            'publish_posts',
252            'write',
253            'wpcom_write_render_admin_page'
254        );
255    }
256);
257
258/**
259 * Where a daily-prompt answer should go instead of Write, if anywhere.
260 *
261 * The prompt surfaces on wordpress.com — the My Home and Reader cards, the
262 * notification and the email — cannot read the opt-out, so it is asked again on
263 * arrival. See view.js's markBlockEditorPreferred() for who writes it.
264 *
265 * @return string Block editor URL to divert to, or '' to let Write render.
266 */
267function wpcom_write_prompt_block_editor_url() {
268    // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only GET routing, no state change.
269    if ( ! isset( $_GET['page'] ) || 'write' !== $_GET['page'] ) {
270        return '';
271    }
272
273    // Anything naming a post to open is left alone; only a blank new post is diverted.
274    if ( ! empty( $_GET['post'] ) || ! empty( $_GET['url'] ) ) {
275        return '';
276    }
277
278    // The opt-out means "I answer prompts in blocks", not "never open Write", so
279    // the Reader's Write button and the /write-editor picker are untouched.
280    $answer_prompt_id = isset( $_GET['answer_prompt'] ) && is_scalar( $_GET['answer_prompt'] ) ? absint( $_GET['answer_prompt'] ) : 0;
281    // phpcs:enable WordPress.Security.NonceVerification.Recommended
282    if ( ! $answer_prompt_id ) {
283        return '';
284    }
285
286    // Exactly the value view.js writes, so all three readers agree on what counts.
287    if ( ! isset( $_COOKIE[ WPCOM_WRITE_BLOCK_EDITOR_PREFERRED_COOKIE ] ) || '1' !== $_COOKIE[ WPCOM_WRITE_BLOCK_EDITOR_PREFERRED_COOKIE ] ) {
288        return '';
289    }
290
291    // Refused either way — leave the arrival on the path it was already taking.
292    if ( ! current_user_can( 'publish_posts' ) ) {
293        return '';
294    }
295
296    $host = new Host();
297    // Simple runs no editor script to insert the prompt block, so post-new.php
298    // would open empty there. Same reason the widget's link sends Simple to Calypso.
299    if ( $host->is_wpcom_simple() ) {
300        return 'https://wordpress.com/post/' . $host->get_wpcom_site_id() . '?answer_prompt=' . $answer_prompt_id;
301    }
302
303    return admin_url( 'post-new.php?answer_prompt=' . $answer_prompt_id );
304}
305
306/**
307 * Divert a prompt answer to the Block editor before Write renders.
308 *
309 * Must run before wpcom_write_render_admin_page(), which sends headers and
310 * fires `wpcom_write_editor_open`.
311 */
312function wpcom_write_divert_prompt_to_block_editor() {
313    // admin_init also fires on admin-ajax.php and admin-post.php, where this
314    // would replace a response body with a redirect.
315    if ( wp_doing_ajax() || ( isset( $GLOBALS['pagenow'] ) && 'admin.php' !== $GLOBALS['pagenow'] ) ) {
316        return;
317    }
318
319    $url = wpcom_write_prompt_block_editor_url();
320    if ( ! $url ) {
321        return;
322    }
323
324    // Simple's destination is off-host, so wp_safe_redirect() needs it allowed.
325    // A rejected host falls back to the dashboard rather than failing, which
326    // would drop the answer silently, so confirm it survives validation first.
327    add_filter( 'allowed_redirect_hosts', array( Host::class, 'allow_wpcom_environments' ) );
328    if ( $url !== wp_validate_redirect( $url, '' ) ) {
329        remove_filter( 'allowed_redirect_hosts', array( Host::class, 'allow_wpcom_environments' ) );
330        return;
331    }
332
333    if ( function_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom\Common\wpcom_record_tracks_event' ) ) {
334        // Names the surface the answer came from, once wordpress.com sends it.
335        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only GET parameter, no state change.
336        $source = isset( $_GET['source'] ) ? sanitize_key( $_GET['source'] ) : '';
337        Common\wpcom_record_tracks_event(
338            'wpcom_write_prompt_diverted_to_block_editor',
339            array( 'source' => $source )
340        );
341    }
342
343    wp_safe_redirect( $url );
344    exit; // @codeCoverageIgnore -- the tests unwind from the wp_redirect filter before this line.
345}
346add_action( 'admin_init', 'wpcom_write_divert_prompt_to_block_editor' ); // @codeCoverageIgnore
347
348/**
349 * Enqueue Write assets only on the Write admin page.
350 */
351add_action(
352    'admin_enqueue_scripts',
353    function () {
354        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page routing.
355        if ( ! isset( $_GET['page'] ) || 'write' !== $_GET['page'] ) {
356            return;
357        }
358
359        wp_enqueue_script_module( 'wpcom-write/view' );
360
361        // Pass translated strings to JavaScript for dynamic messages.
362        wp_print_inline_script_tag(
363            'window.wpcomWriteStrings = ' . wp_json_encode( wpcom_write_get_editor_strings(), JSON_HEX_TAG | JSON_HEX_AMP ) . ';'
364        );
365
366        // No style dependencies: every icon is an inline SVG (see icons.php),
367        // so the editor no longer pulls in the dashicons font.
368        wp_enqueue_style(
369            'wpcom-write',
370            wpcom_write_asset_url( 'style.css' ),
371            array(),
372            WPCOM_WRITE_VERSION
373        );
374
375        // Add CSS to hide wp-admin chrome for a full-screen writing experience.
376        // This works with the platform rather than against it — wp-admin's scripts
377        // (including wp-api-fetch middleware) are fully loaded and configured.
378        $hide_chrome_css = '
379            #wpadminbar,
380            #adminmenuwrap,
381            #adminmenuback,
382            #adminmenumain,
383            #wpfooter,
384            .wp-admin-bar-fix { display: none !important; }
385            #wpcontent,
386            #wpbody,
387            #wpbody-content { margin-left: 0 !important; padding: 0 !important; }
388            .wrap { margin: 0 !important; padding: 0 !important; max-width: none !important; }
389            html.wp-toolbar { padding-top: 0 !important; }
390            body.admin-bar { padding-top: 0 !important; margin-top: 0 !important; }
391            html { margin-top: 0 !important; }
392        ';
393        wp_add_inline_style( 'wpcom-write', $hide_chrome_css );
394    }
395);
396
397/**
398 * Drop wp-admin notices on the Write page, where the fixed editor header
399 * covers them and leaves only a clipped strip visible.
400 *
401 * Runs on in_admin_header, the last hook before admin-header.php prints notices,
402 * so notices registered late are caught too.
403 */
404function wpcom_write_remove_admin_notices() {
405    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page routing.
406    if ( ! isset( $_GET['page'] ) || 'write' !== $_GET['page'] ) {
407        return;
408    }
409    remove_all_actions( 'admin_notices' );
410    remove_all_actions( 'all_admin_notices' );
411    remove_all_actions( 'network_admin_notices' );
412    remove_all_actions( 'user_admin_notices' );
413}
414add_action( 'in_admin_header', 'wpcom_write_remove_admin_notices', PHP_INT_MAX );
415
416/**
417 * Convert wp:embed video blocks to placeholder tokens for the Write editor.
418 *
419 * Video embed blocks are replaced with inert HTML comment tokens that survive
420 * the_content filters, wp_kses_post(), and the pre_kses filter chain (which
421 * converts YouTube iframes to shortcodes on non-frontend requests).  The
422 * returned placeholders map is used to swap tokens for real iframe HTML after
423 * all sanitization is complete.
424 *
425 * @param string $content Raw post_content (block markup).
426 * @return array { content: string, placeholders: array<string,string> }
427 */
428function wpcom_write_convert_video_embeds( $content ) {
429    $placeholders = array();
430
431    $replaced = preg_replace_callback(
432        '/<!-- wp:embed (\{[^}]*"type"\s*:\s*"video"[^}]*\}) -->.+?<!-- \/wp:embed -->/s',
433        static function ( $matches ) use ( &$placeholders ) {
434            $attrs = json_decode( $matches[1], true );
435            if ( ! is_array( $attrs ) || empty( $attrs['url'] ) ) {
436                return $matches[0];
437            }
438            $url       = $attrs['url'];
439            $embed_url = '';
440
441            // YouTube.
442            if ( preg_match( '/(?:youtube\.com\/watch\?(?:[^&]*&)*v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/', $url, $yt ) ) {
443                $embed_url = 'https://www.youtube.com/embed/' . $yt[1];
444            }
445            // Vimeo.
446            if ( ! $embed_url && preg_match( '/vimeo\.com\/(\d+)/', $url, $vim ) ) {
447                $embed_url = 'https://player.vimeo.com/video/' . $vim[1];
448            }
449
450            if ( ! $embed_url ) {
451                return $matches[0];
452            }
453
454            $title = $yt ? __( 'YouTube video', 'jetpack-mu-wpcom' ) : __( 'Vimeo video', 'jetpack-mu-wpcom' );
455            $token = '<!--WRITE_VIDEO_' . md5( $embed_url ) . '-->';
456            $html  = sprintf(
457                '<figure class="bw-video-figure"><div class="bw-video-wrap"><iframe src="%s" title="%s" frameborder="0" allowfullscreen></iframe></div></figure>',
458                esc_url( $embed_url ),
459                esc_attr( $title )
460            );
461
462            $placeholders[ $token ] = $html;
463            return $token;
464        },
465        $content
466    );
467
468    return array(
469        'content'      => $replaced ?? $content,
470        'placeholders' => $placeholders,
471    );
472}
473
474/**
475 * Return the per-block-type attribute allowlist for Write.
476 *
477 * Attributes listed here are safe for Write to handle — either preserved by
478 * convertToBlocks() in view.js or metadata that doesn't affect visible
479 * content.  Keep this in sync with convertToBlocks(); a PHPUnit test
480 * cross-checks that both sides list the same block types.
481 *
482 * @return array<string, string[]> Block type → allowed attribute keys.
483 */
484function wpcom_write_allowed_block_attrs() {
485    return array(
486        'paragraph' => array( 'align' ),
487        'heading'   => array( 'level', 'align' ),
488        // id: media-library metadata, not visible formatting.
489        // alt: preserved via HTML element, not block JSON.
490        // sizeSlug: thumbnail/medium/large/full size presets.
491        // align: left/center/right via the image properties panel.  Wide and
492        // full alignment values are rejected by wpcom_write_has_unsupported_blocks
493        // further down (the same value check used for paragraph/heading),
494        // so posts using those bounce to the block editor.
495        'image'     => array( 'id', 'sizeSlug', 'alt', 'align' ),
496        'embed'     => array( 'url', 'type', 'providerNameSlug', 'responsive' ),
497        'quote'     => array( 'align', 'citation' ),
498        'list'      => array( 'ordered' ),
499        'list-item' => array(),
500        'separator' => array(),
501    );
502}
503
504/**
505 * Detect whether a post contains features the Write editor cannot preserve.
506 *
507 * Uses parse_blocks() to inspect block types and attributes against an
508 * allowlist derived from what convertToBlocks() in view.js can round-trip.
509 * Any block type or attribute not in the allowlist will trigger a warning.
510 *
511 * @param string $content Raw post_content (block markup).
512 * @return string|false 'classic-editor' or 'block-editor' indicating the warning
513 *                      type, or false when no warning is needed.
514 */
515function wpcom_write_detect_unsupported_content( $content ) {
516    if ( empty( $content ) ) {
517        return false;
518    }
519
520    if ( ! has_blocks( $content ) ) {
521        return 'classic-editor';
522    }
523
524    $allowed_attrs = wpcom_write_allowed_block_attrs();
525
526    $blocks = parse_blocks( $content );
527
528    if ( wpcom_write_has_unsupported_blocks( $blocks, $allowed_attrs ) ) {
529        return 'block-editor';
530    }
531
532    // Write supports inline custom text colors only.
533    // Palette colors and highlights stay block-editor-only.
534    // has-text-align-* is not checked — it's converted to inline styles on load.
535    if ( wpcom_write_has_unsupported_color_class( $content ) ) {
536        return 'block-editor';
537    }
538
539    if ( wpcom_write_has_unsupported_mark( $content ) ) {
540        return 'block-editor';
541    }
542
543    return false;
544}
545
546/**
547 * Check whether a CSS background-color value is transparent.
548 *
549 * Matches "transparent", "rgba(0, 0, 0, 0)", and whitespace/case variants.
550 *
551 * @param string $value A CSS background-color value.
552 * @return bool True if the value is transparent.
553 */
554function wpcom_write_is_transparent_background( $value ) {
555    $value = strtolower( trim( $value ) );
556    if ( 'transparent' === $value ) {
557        return true;
558    }
559    // Normalize whitespace so rgba(0,0,0,0) and rgba( 0, 0, 0, 0 ) both match.
560    $value = preg_replace( '/\s+/', '', $value );
561    return 'rgba(0,0,0,0)' === $value;
562}
563
564/**
565 * Check whether content has color classes that Write can't preserve.
566 *
567 * Palette classes (has-vivid-red-color, etc.) and has-text-color indicate
568 * block editor content.  has-inline-color alone is fine — Write produces it.
569 * Only matches inside class attributes to avoid false positives from plain text.
570 *
571 * @param string $content Raw post_content.
572 * @return bool True if unsupported color classes are found.
573 */
574function wpcom_write_has_unsupported_color_class( $content ) {
575    return preg_match( '/class="[^"]*\bhas-(?!inline-color\b)[\w-]+-color\b[^"]*"/', $content ) ||
576        preg_match( "/class='[^']*\bhas-(?!inline-color\b)[\w-]+-color\b[^']*'/", $content );
577}
578
579/**
580 * Check whether content has <mark> elements that Write can't round-trip.
581 *
582 * Catches two cases:
583 * - <mark> without has-inline-color (block editor highlights).
584 * - <mark> with has-inline-color AND a non-transparent background
585 *   (combined text color + highlight from the block editor).
586 *
587 * @param string $content Raw post_content.
588 * @return bool True if unsupported marks are found.
589 */
590function wpcom_write_has_unsupported_mark( $content ) {
591    // Highlights: <mark> without has-inline-color.
592    if ( preg_match( '/<mark\b(?![^>]*\bclass="[^"]*has-inline-color)/', $content ) ) {
593        return true;
594    }
595
596    // Combined text color + highlight: has-inline-color with a real background.
597    if ( preg_match_all( '/<mark\b[^>]*\bclass="[^"]*has-inline-color[^"]*"[^>]*>/', $content, $marks ) ) {
598        foreach ( $marks[0] as $tag ) {
599            if ( preg_match( '/background-color\s*:\s*([^;"]+)/', $tag, $bg ) ) {
600                if ( ! wpcom_write_is_transparent_background( $bg[1] ) ) {
601                    return true;
602                }
603            }
604        }
605    }
606
607    return false;
608}
609
610/**
611 * Check whether any block in the tree has an unsupported type or attributes.
612 *
613 * @param array $blocks        Parsed block array from parse_blocks().
614 * @param array $allowed_attrs Per-block-type allowed attribute keys.
615 * @return bool True if any block is unsupported.
616 */
617function wpcom_write_has_unsupported_blocks( $blocks, $allowed_attrs ) {
618    foreach ( $blocks as $block ) {
619        // Skip freeform HTML / whitespace between blocks.
620        if ( empty( $block['blockName'] ) ) {
621            continue;
622        }
623
624        // Normalize: strip 'core/' namespace prefix.
625        $name = $block['blockName'];
626        if ( strpos( $name, 'core/' ) === 0 ) {
627            $name = substr( $name, 5 );
628        }
629
630        // Unknown block type (including third-party namespaced blocks).
631        if ( ! isset( $allowed_attrs[ $name ] ) ) {
632            return true;
633        }
634
635        // Attributes beyond what Write handles for this block type.
636        $attrs = $block['attrs'] ?? array();
637
638        // The block editor stores alignment in two ways:
639        // 1. Top-level: {"align":"center"}
640        // 2. Nested style: {"style":{"typography":{"textAlign":"center"}}}
641        // Both are converted to inline styles on load, so they round-trip.
642        // Strip the style attr if it only contains typography.textAlign
643        // with a supported value, so it doesn't trigger the extra-attrs check.
644        // Justify is paragraph-only.
645        $supported_text_aligns = 'paragraph' === $name
646            ? array( 'left', 'center', 'right', 'justify' )
647            : array( 'left', 'center', 'right' );
648        $style_align           = $attrs['style']['typography']['textAlign'] ?? '';
649        if (
650            isset( $attrs['style'] ) &&
651            $style_align &&
652            in_array( $style_align, $supported_text_aligns, true ) &&
653            array( 'typography' => array( 'textAlign' => $style_align ) ) === $attrs['style']
654        ) {
655            unset( $attrs['style'] );
656        }
657
658        // Justify uses the canonical class "has-text-align-justify" on paragraph.
659        // Strip it before the extra-attrs check so the paragraph round-trips.
660        if ( 'paragraph' === $name && ( $attrs['className'] ?? '' ) === 'has-text-align-justify' ) {
661            unset( $attrs['className'] );
662        }
663
664        // The block editor stamps `linkDestination: "none"` onto image
665        // blocks by default, even when the user never set a link.  Strip
666        // that no-op value so block-editor-created images round-trip into
667        // Write.  Actual link configurations (media / attachment / custom)
668        // still flag as unsupported below since Write has no image-link UI.
669        if ( 'image' === $name && ( $attrs['linkDestination'] ?? '' ) === 'none' ) {
670            unset( $attrs['linkDestination'] );
671        }
672
673        $extra = array_diff( array_keys( $attrs ), $allowed_attrs[ $name ] );
674        if ( ! empty( $extra ) ) {
675            return true;
676        }
677
678        // Alignment: convertToBlocks() only preserves center and right
679        // (read from style.textAlign). Block-level values like wide/full
680        // are CSS-class-based and silently lost. Left is the default and
681        // renders identically to no alignment, so allow it too. Justify
682        // is paragraph-only (display type and narrow quotes look poor).
683        if ( isset( $attrs['align'] ) && ! in_array( $attrs['align'], $supported_text_aligns, true ) ) {
684            return true;
685        }
686
687        // Image sizeSlug: convertToBlocks() only emits the four standard
688        // presets. Custom or theme-registered slugs (e.g. "hero") would be
689        // silently stripped on save, so bounce them to the block editor.
690        if ( 'image' === $name && isset( $attrs['sizeSlug'] ) && ! in_array( $attrs['sizeSlug'], array( 'thumbnail', 'medium', 'large', 'full' ), true ) ) {
691            return true;
692        }
693
694        // Embed-specific: only YouTube and Vimeo video embeds round-trip.
695        if ( 'embed' === $name ) {
696            $type     = $attrs['type'] ?? '';
697            $provider = $attrs['providerNameSlug'] ?? '';
698            if ( 'video' !== $type || ! in_array( $provider, array( 'youtube', 'vimeo' ), true ) ) {
699                return true;
700            }
701        }
702
703        // Recurse into inner blocks (e.g. list → list-item).
704        if ( ! empty( $block['innerBlocks'] ) ) {
705            if ( wpcom_write_has_unsupported_blocks( $block['innerBlocks'], $allowed_attrs ) ) {
706                return true;
707            }
708        }
709    }
710
711    return false;
712}
713
714/**
715 * Convert block-editor alignment CSS classes to inline styles.
716 *
717 * The block editor uses `has-text-align-{left,center,right}` classes for
718 * alignment, but Write's convertToBlocks() reads alignment from
719 * node.style.textAlign.  This converts the classes to inline styles so
720 * alignment round-trips through Write.
721 *
722 * @param string $html Rendered HTML content.
723 * @return string HTML with alignment classes replaced by inline styles.
724 */
725function wpcom_write_alignment_classes_to_inline( $html ) {
726    return preg_replace_callback(
727        '/(<(?:p|h[1-6]|blockquote)\b[^>]*?)class="([^"]*has-text-align-(left|center|right|justify)[^"]*)"([^>]*?>)/',
728        function ( $m ) {
729            $before  = $m[1];
730            $classes = $m[2];
731            $align   = $m[3];
732            $after   = $m[4];
733
734            // Remove the has-text-align-* class.
735            $classes = trim( preg_replace( '/\bhas-text-align-(?:left|center|right|justify)\b/', '', $classes ) );
736            $classes = preg_replace( '/  +/', ' ', $classes );
737
738            // Build the new tag, merging alignment into any existing style
739            // attribute to avoid producing invalid duplicate style attrs.
740            $class_attr = $classes ? ' class="' . $classes . '"' : '';
741            $tag        = rtrim( $before ) . $class_attr . $after;
742
743            if ( preg_match( '/style="([^"]*)"/', $tag, $sm ) ) {
744                // Skip prepending if the existing style attr already declares
745                // the same text-align (avoids duplicate text-align declarations
746                // when the saved HTML carries both class and inline style).
747                if ( strpos( $sm[1], 'text-align:' . $align ) === false ) {
748                    $tag = preg_replace( '/style="/', 'style="text-align:' . $align . ';', $tag, 1 );
749                }
750            } else {
751                $tag = preg_replace( '/>$/', ' style="text-align:' . $align . '">', $tag );
752            }
753
754            return $tag;
755        },
756        $html
757    );
758}
759
760/**
761 * Convert Gutenberg inline-color marks to spans for contentEditable.
762 *
763 * Write edits colors as spans, saves colors as Gutenberg marks.
764 * On load, marks are converted back to spans so foreColor can interact
765 * with existing colors. normalizeColorMarkup() in view.js reverses this.
766 *
767 * @param string $html Rendered HTML content.
768 * @return string HTML with inline-color marks replaced by color spans.
769 */
770function wpcom_write_inline_color_marks_to_spans( $html ) {
771    return preg_replace_callback(
772        '/<mark\b([^>]*\bclass="[^"]*has-inline-color[^"]*"[^>]*)>(.*?)<\/mark>/s',
773        function ( $m ) {
774            $attrs = $m[1];
775            $inner = $m[2];
776            // Extract the color value from the style attribute.
777            // Use (?<!-) to match the `color` property but not `background-color`.
778            if ( preg_match( '/(?<!-)color\s*:\s*([^;"]+)/', $attrs, $cm ) ) {
779                return '<span style="color:' . $cm[1] . '">' . $inner . '</span>';
780            }
781            // No color found — just unwrap to a plain span.
782            return '<span>' . $inner . '</span>';
783        },
784        $html
785    );
786}
787
788/**
789 * Build the seed content for a post that answers a daily writing prompt.
790 *
791 * Write can't hold the jetpack/blogging-prompt block — it round-trips only a
792 * fixed block vocabulary (see wpcom_write_allowed_block_attrs()), so the prompt
793 * is seeded as a quote block. convertToBlocks() in view.js turns a top-level
794 * <blockquote class="wp-block-quote"> back into a wp:quote block on save, and
795 * preserves a <cite> as the quote's citation. When a responses link is
796 * available it becomes the citation, so the published quote points readers at
797 * the other answers — the one piece of the blogging-prompt block worth keeping.
798 *
799 * @since 6.11.0
800 *
801 * @param string $prompt_text   The prompt text from the blogging-prompts endpoint.
802 * @param string $citation_text Optional citation text (e.g. the responses link text).
803 * @param string $citation_url  Optional URL to link the citation to (e.g. the responses page).
804 * @return string Quote block markup, or '' when there is no usable text.
805 */
806function wpcom_write_prompt_quote_markup( $prompt_text, $citation_text = '', $citation_url = '' ) {
807    $prompt_text = trim( wp_strip_all_tags( html_entity_decode( (string) $prompt_text, ENT_QUOTES, 'UTF-8' ) ) );
808    if ( '' === $prompt_text ) {
809        return '';
810    }
811
812    $markup = '<blockquote class="wp-block-quote"><p>' . esc_html( $prompt_text ) . '</p>';
813
814    $citation_text = trim( wp_strip_all_tags( html_entity_decode( (string) $citation_text, ENT_QUOTES, 'UTF-8' ) ) );
815    if ( '' !== $citation_text ) {
816        $cite = esc_html( $citation_text );
817        if ( '' !== $citation_url ) {
818            $cite = '<a href="' . esc_url( $citation_url ) . '" target="_blank" rel="noreferrer noopener">' . $cite . '</a>';
819        }
820        $markup .= '<cite>' . $cite . '</cite>';
821    }
822
823    return $markup . '</blockquote>';
824}
825
826/**
827 * Get the current user's recent Write-compatible drafts.
828 *
829 * Queries up to 20 drafts by post_modified desc, filters out posts with
830 * unsupported content, and returns the first 5 that pass.
831 *
832 * @param int $exclude_post_id Post ID to exclude (the currently-edited post), or 0.
833 * @return array Array of { id: int, title: string, modified: string } objects.
834 */
835function wpcom_write_get_recent_drafts( $exclude_post_id = 0 ) {
836    // Drafts always belong to the current user; without one there is nothing to
837    // return, and querying with author=0 would otherwise match orphaned drafts.
838    if ( ! is_user_logged_in() ) {
839        return array();
840    }
841
842    $args = array(
843        'post_type'      => 'post',
844        'post_status'    => 'draft',
845        'author'         => get_current_user_id(),
846        'orderby'        => 'modified',
847        'order'          => 'DESC',
848        'posts_per_page' => 20,
849        'no_found_rows'  => true,
850    );
851
852    if ( $exclude_post_id ) {
853        $args['post__not_in'] = array( $exclude_post_id );
854    }
855
856    $query  = new WP_Query( $args );
857    $drafts = array();
858
859    foreach ( $query->posts as $post ) {
860        if ( count( $drafts ) >= 5 ) {
861            break;
862        }
863
864        if ( wpcom_write_detect_unsupported_content( $post->post_content ) ) {
865            continue;
866        }
867
868        $gmt = $post->post_modified_gmt;
869        if ( '0000-00-00 00:00:00' !== $gmt ) {
870            $mod = str_replace( ' ', 'T', $gmt ) . 'Z';
871        } else {
872            // Convert local time to UTC using the site's timezone setting
873            // so the browser can compute an accurate relative time.
874            $tz = wp_timezone();
875            $dt = new \DateTime( $post->post_modified, $tz );
876            $dt->setTimezone( new \DateTimeZone( 'UTC' ) );
877            $mod = $dt->format( 'Y-m-d\TH:i:s' ) . 'Z';
878        }
879
880        $drafts[] = array(
881            'id'       => $post->ID,
882            'title'    => $post->post_title,
883            'modified' => $mod,
884        );
885    }
886
887    return $drafts;
888}
889
890/**
891 * Render the Write admin page.
892 *
893 * Called by add_submenu_page as the page callback. Runs inside wp-admin's
894 * normal page lifecycle, so wp.apiFetch is fully configured with the correct
895 * REST root URL and auth middleware.
896 */
897function wpcom_write_render_admin_page() {
898    // Check if editing an existing post.
899    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only GET parameter, gated by capability check via add_submenu_page.
900    $edit_post_id       = isset( $_GET['post'] ) && is_scalar( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;
901    $edit_title         = '';
902    $edit_content       = '';
903    $post_status        = 'new';
904    $edit_featured_id   = 0;
905    $video_placeholders = array();
906    $unsupported_type   = false;
907    $open_post_error    = '';
908
909    // Resolve a ?url= param to a post ID.
910    // For same-host URLs, extract ?p= or ?post= query params directly
911    // (covers admin URLs and shortlinks like /?p=123). Then fall back
912    // to url_to_postid() for pretty permalinks — core checks the host
913    // against home_url() internally.
914    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only GET parameter for permalink resolution.
915    if ( ! $edit_post_id && ! empty( $_GET['url'] ) && is_scalar( $_GET['url'] ) ) {
916        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
917        $input_url = esc_url_raw( wp_unslash( $_GET['url'] ) );
918        $url_host  = wp_parse_url( $input_url, PHP_URL_HOST );
919        $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
920
921        // Extract ?p= / ?post= only from same-host URLs to avoid
922        // treating foreign post IDs as local ones.
923        if ( $url_host && $url_host === $home_host ) {
924            $query_str = wp_parse_url( $input_url, PHP_URL_QUERY );
925            if ( $query_str ) {
926                parse_str( $query_str, $query_params );
927                $param_id = 0;
928                if ( ! empty( $query_params['p'] ) && is_scalar( $query_params['p'] ) ) {
929                    $param_id = absint( $query_params['p'] );
930                } elseif ( ! empty( $query_params['post'] ) && is_scalar( $query_params['post'] ) ) {
931                    $param_id = absint( $query_params['post'] );
932                }
933                if ( $param_id ) {
934                    $edit_post_id = $param_id;
935                }
936            }
937        }
938        if ( ! $edit_post_id ) {
939            $resolved_id = url_to_postid( $input_url );
940            if ( $resolved_id ) {
941                $edit_post_id = $resolved_id;
942            } else {
943                $open_post_error = __( 'Post not found. Check the URL or ID and try again.', 'jetpack-mu-wpcom' );
944            }
945        }
946    }
947
948    if ( $edit_post_id ) {
949        $edit_post = get_post( $edit_post_id );
950        if ( ! $edit_post ) {
951            $open_post_error = __( 'Post not found. Check the URL or ID and try again.', 'jetpack-mu-wpcom' );
952            $edit_post_id    = 0;
953        } elseif ( ! current_user_can( 'edit_post', $edit_post_id ) ) {
954            $open_post_error = __( 'You don\'t have permission to edit this post.', 'jetpack-mu-wpcom' );
955            $edit_post_id    = 0;
956        } elseif ( 'post' !== $edit_post->post_type ) {
957            $open_post_error = __( 'Only posts can be opened in Write.', 'jetpack-mu-wpcom' );
958            $edit_post_id    = 0;
959        } else {
960            $edit_title = $edit_post->post_title;
961            // Convert video embed blocks to inert placeholder tokens before the
962            // the_content + wp_kses_post pipeline runs.  Tokens survive all filters;
963            // real iframe HTML is swapped in after sanitization in the template.
964            $video_result = wpcom_write_convert_video_embeds( $edit_post->post_content );
965            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter needed to render blocks.
966            $edit_content       = apply_filters( 'the_content', $video_result['content'] );
967            $video_placeholders = $video_result['placeholders'];
968            $post_status        = $edit_post->post_status;
969            $edit_featured_id   = (int) get_post_thumbnail_id( $edit_post_id );
970            $unsupported_type   = wpcom_write_detect_unsupported_content( $edit_post->post_content );
971
972            // Only track the last editor when the post is actually editable
973            // in Write. Posts with unsupported content show a warning modal
974            // and are never editable, so recording Write as the last editor
975            // would be misleading.
976            if ( ! $unsupported_type ) {
977                \Automattic\Jetpack\Jetpack_Mu_Wpcom\WPCOM_Block_Editor\EditorType\remember_editor( $edit_post_id, 'write-editor' );
978            }
979        }
980    }
981
982    // Seed a brand-new post that's answering a daily writing prompt. The id
983    // arrives as ?answer_prompt=<id> — forwarded by the wp-admin Daily Writing
984    // Prompt widget and (later) by wpcom's /write-editor redirect. Only new
985    // posts are seeded; opening an existing post ignores the param. The param is
986    // an integer id only (never rendered text) and the prompt text comes from
987    // the trusted blogging-prompts endpoint, mirroring the block-editor flow in
988    // plugins/jetpack (_inc/blogging-prompts.php).
989    $answer_prompt_id = 0;
990    if ( 0 === $edit_post_id ) {
991        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only GET param; seeds a new draft only, gated by the page's publish_posts capability. Mirrors _inc/blogging-prompts.php.
992        $answer_prompt_id = isset( $_GET['answer_prompt'] ) ? absint( $_GET['answer_prompt'] ) : 0;
993        if ( $answer_prompt_id && function_exists( 'jetpack_get_blogging_prompt_by_id' ) ) {
994            // @phan-suppress-next-line PhanUndeclaredFunction -- Defined in the Jetpack plugin (_inc/blogging-prompts.php); present at runtime when Jetpack is active, guarded by the function_exists() check above.
995            $prompt = (array) jetpack_get_blogging_prompt_by_id( $answer_prompt_id );
996            if ( ! empty( $prompt['text'] ) ) {
997                $edit_content = wpcom_write_prompt_quote_markup(
998                    $prompt['text'],
999                    $prompt['answered_link_text'] ?? '',
1000                    $prompt['answered_link'] ?? ''
1001                );
1002            }
1003        }
1004    }
1005
1006    if ( 'classic-editor' === $unsupported_type ) {
1007        $editor_url = admin_url( 'post.php?post=' . $edit_post_id . '&action=edit&classic-editor' );
1008    } elseif ( 'block-editor' === $unsupported_type ) {
1009        $editor_url = admin_url( 'post.php?post=' . $edit_post_id . '&action=edit&classic-editor__forget' );
1010    } else {
1011        $editor_url = '';
1012    }
1013
1014    // URLs for the topbar "more" menu (Open in block editor / Preview).
1015    // Always available when editing an existing post — independent of the
1016    // unsupported-content warning's $editor_url which only triggers on load.
1017    $block_editor_url = $edit_post_id
1018        ? admin_url( 'post.php?post=' . $edit_post_id . '&action=edit&classic-editor__forget' )
1019        : '';
1020    $preview_url      = '';
1021    if ( $edit_post_id ) {
1022        $preview_url = 'publish' === $post_status
1023            ? (string) get_permalink( $edit_post_id )
1024            : (string) get_preview_post_link( $edit_post_id );
1025    }
1026
1027    // Determine how the user arrived at the Write editor.
1028    // 1. Explicit query param (highest priority).
1029    // 2. Infer from HTTP referer.
1030    // 3. Fall back to 'direct' (bookmarks, typed URLs, stripped referers).
1031    // The /write-editor redirect forwards this param into wp-admin, so an explicit
1032    // source (e.g. 'reader') survives the hop and drives the back-button destination.
1033    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only GET parameter, no state change.
1034    $source = isset( $_GET['source'] ) ? sanitize_key( $_GET['source'] ) : '';
1035    if ( ! $source ) {
1036        $referer = wp_get_referer();
1037        if ( $referer ) {
1038            $path = wp_parse_url( $referer, PHP_URL_PATH );
1039            if ( $path && str_contains( $path, '/wp-admin/index.php' ) ) {
1040                $source = 'dashboard';
1041            } elseif ( $path && str_contains( $path, '/wp-admin/edit.php' ) ) {
1042                $source = 'posts_list';
1043            } else {
1044                $source = 'referrer';
1045            }
1046        } else {
1047            $source = 'direct';
1048        }
1049    }
1050
1051    // Resolve where the back/close button should return the user, based on source.
1052    $back_url = wpcom_write_resolve_back_url( $source );
1053
1054    if ( function_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom\Common\wpcom_record_tracks_event' ) ) {
1055        $event_props = array(
1056            'is_new_post' => (int) ( 0 === $edit_post_id ),
1057            'source'      => $source,
1058            // Anon entry is the only logged-out render of this editor (the wp-admin
1059            // page requires auth), so logged-out is a reliable proxy for the anon
1060            // fake-door funnel. Lets the funnel scope its top-of-funnel denominator
1061            // to anon traffic without depending on the client-only wpcomWriteIsAnon flag.
1062            'is_anon'     => (int) ! is_user_logged_in(),
1063        );
1064
1065        if ( $edit_post_id > 0 ) {
1066            $event_props['post_id'] = $edit_post_id;
1067        }
1068
1069        Common\wpcom_record_tracks_event(
1070            'wpcom_write_editor_open',
1071            $event_props
1072        );
1073    }
1074
1075    // Fetch existing tag IDs so JS can merge them with any #hashtag-extracted tags on save.
1076    $existing_tag_ids = $edit_post_id ? wp_get_post_tags( $edit_post_id, array( 'fields' => 'ids' ) ) : array();
1077
1078    // Build categories list for the UI (only categories that have posts).
1079    $all_cats        = get_categories( array( 'hide_empty' => true ) );
1080    $selected_cats   = $edit_post_id ? wp_get_post_categories( $edit_post_id ) : array();
1081    $categories_data = array();
1082    foreach ( $all_cats as $cat ) {
1083        $categories_data[] = array(
1084            'id'       => $cat->term_id,
1085            'name'     => $cat->name,
1086            'selected' => in_array( $cat->term_id, $selected_cats, true ),
1087        );
1088    }
1089
1090    // For new posts, pre-select the default category if it's in the used-categories list,
1091    // otherwise the first available used category, so the label is never empty.
1092    if ( ! $edit_post_id && ! empty( $categories_data ) ) {
1093        $has_selection = ! empty( array_filter( $categories_data, fn( $c ) => $c['selected'] ) );
1094        if ( ! $has_selection ) {
1095            $default_id                                 = (int) get_option( 'default_category' );
1096            $default_idx                                = array_search( $default_id, array_column( $categories_data, 'id' ), true );
1097            $select_idx                                 = false !== $default_idx ? $default_idx : 0;
1098            $categories_data[ $select_idx ]['selected'] = true;
1099        }
1100    }
1101
1102    // Show the category row only when the site has 2+ used categories.
1103    $show_cat_row = count( $categories_data ) >= 2;
1104
1105    // Compute the initial "Writing in …" label from any pre-selected categories.
1106    $selected_cat_names = array_values(
1107        array_map(
1108            fn( $c ) => $c['name'],
1109            array_filter( $categories_data, fn( $c ) => $c['selected'] )
1110        )
1111    );
1112    // translators: %s is a comma-separated list of category names, e.g. "Travel, Food".
1113    $writing_in_fmt = __( 'Writing in %s', 'jetpack-mu-wpcom' );
1114    $cat_label      = empty( $selected_cat_names )
1115        ? ''
1116        : sprintf( $writing_in_fmt, implode( ', ', $selected_cat_names ) );
1117
1118    // Query recent compatible drafts for the post picker.
1119    $recent_drafts = wpcom_write_get_recent_drafts( $edit_post_id );
1120
1121    // Seed Interactivity API state.
1122    wp_interactivity_state(
1123        'wpcom-write',
1124        array(
1125            'postsPath'              => '/wp/v2/posts',
1126            'mediaPath'              => '/wp/v2/media',
1127            'homeUrl'                => home_url( '/' ),
1128            'adminUrl'               => admin_url(),
1129            'backUrl'                => $back_url,
1130            'writeUrl'               => wpcom_write_url(),
1131            'editPostId'             => $edit_post_id,
1132            'postStatus'             => $post_status,
1133            'isPublishedPost'        => 'publish' === $post_status,
1134            // The query arg the redirect tags onto the post URL, kept in sync with
1135            // the server-side gate by sharing WPCOM_WRITE_PUBLISHED_MARKER (defined
1136            // in post-publish-checklist.php) rather than hardcoding it in view.js.
1137            'publishedMarker'        => WPCOM_WRITE_PUBLISHED_MARKER,
1138            // The entry point this editor session was opened from, forwarded onto
1139            // the post-publish redirect so survey responses segment by the same
1140            // `source` the funnel's wpcom_write_editor_open event records.
1141            'source'                 => $source,
1142            'title'                  => $edit_title,
1143            'isSaving'               => false,
1144            'isPublished'            => false,
1145            'message'                => '',
1146            'showLinkInput'          => false,
1147            'linkUrl'                => '',
1148            'showImageModal'         => false,
1149            'showVideoModal'         => false,
1150            'videoUrl'               => '',
1151            'imageAlt'               => '',
1152            'setAsFeatured'          => false,
1153            'featuredMediaId'        => $edit_featured_id,
1154            'isEditMode'             => false,
1155            'editingImageAlign'      => 'center',
1156            'editingImageSize'       => '',
1157            'editingImageHasMediaId' => false,
1158            'isUploading'            => false,
1159            'showLibraryPicker'      => false,
1160            'showUrlInput'           => false,
1161            'librarySearch'          => '',
1162            'libraryStatus'          => '',
1163            'categories'             => $categories_data,
1164            'catLabel'               => $cat_label,
1165            'existingTagIds'         => $existing_tag_ids,
1166            'answerPromptId'         => $answer_prompt_id,
1167            'showCatDropdown'        => false,
1168            'showHelp'               => false,
1169            'showSlashMenu'          => false,
1170            'slashActiveId'          => '',
1171            'slashFilter'            => '',
1172            'showLeaveConfirm'       => false,
1173            'showHeadingMenu'        => false,
1174            'showTextColorMenu'      => false,
1175            'formatStrikethrough'    => false,
1176            'formatUnderline'        => false,
1177            'formatAlignLeft'        => false,
1178            'formatAlignCenter'      => false,
1179            'formatAlignRight'       => false,
1180            'formatAlignJustify'     => false,
1181            'cannotJustify'          => false,
1182            'formatOList'            => false,
1183            'formatUList'            => false,
1184            'insideList'             => false,
1185            'showRecoveryBanner'     => false,
1186            // The first-visit note is opened by view.js, which owns the
1187            // per-browser "already seen" flag; the server always renders it hidden.
1188            'showEditorNote'         => false,
1189            'unsupportedWarning'     => $unsupported_type,
1190            'editorUrl'              => $editor_url,
1191            'blockEditorUrl'         => $block_editor_url,
1192            'previewUrl'             => $preview_url,
1193            'showMoreMenu'           => false,
1194            'recentDrafts'           => $recent_drafts,
1195            'openPostError'          => $open_post_error,
1196            'showPostPicker'         => '' !== $open_post_error,
1197            'postPickerUrl'          => '',
1198            'pendingOpenPost'        => false,
1199        )
1200    );
1201
1202    // Output the editor UI inside wp-admin's wrapper.
1203    wpcom_write_template( $edit_title, $edit_content, $edit_post_id, $categories_data, $post_status, $video_placeholders, $show_cat_row, $cat_label, $recent_drafts, $open_post_error, $back_url );
1204}
1205
1206/**
1207 * Render the distraction-free writing UI.
1208 *
1209 * This outputs HTML inside wp-admin's page wrapper (not a standalone page).
1210 * The wp-admin chrome is hidden via CSS added in admin_enqueue_scripts.
1211 *
1212 * @param string $edit_title          The post title when editing.
1213 * @param string $edit_content        The post content when editing.
1214 * @param int    $edit_post_id        The post ID when editing, 0 for new posts.
1215 * @param array  $categories_data     Array of category data for the picker.
1216 * @param string $post_status         The post status ('new', 'draft', 'publish', etc.).
1217 * @param array  $video_placeholders  Map of comment tokens to iframe HTML for video embeds.
1218 * @param bool   $show_cat_row        Whether to show the category row (2+ used categories).
1219 * @param string $cat_label           Full "Writing in X, Y" label text; empty string if none selected.
1220 * @param array  $recent_drafts       Array of recent draft objects for the post picker.
1221 * @param string $open_post_error     Error message for post picker, empty if no error.
1222 * @param string $back_url            Destination for the back/close button; defaults to the dashboard.
1223 */
1224function wpcom_write_template( $edit_title = '', $edit_content = '', $edit_post_id = 0, $categories_data = array(), $post_status = 'new', $video_placeholders = array(), $show_cat_row = false, $cat_label = '', $recent_drafts = array(), $open_post_error = '', $back_url = '' ) {
1225    if ( '' === $back_url ) {
1226        $back_url = admin_url();
1227    }
1228    ?>
1229<div data-wp-interactive="wpcom-write" class="bw-app">
1230
1231    <!-- Top bar -->
1232    <header class="bw-topbar" data-wp-class--has-topbar-message="state.hasMessage">
1233        <a href="<?php echo esc_url( $back_url ); ?>" class="bw-back" title="<?php esc_attr_e( 'Back', 'jetpack-mu-wpcom' ); ?>" aria-label="<?php esc_attr_e( 'Back', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.handleBack"><?php wpcom_write_icon( 'back' ); ?></a>
1234        <div class="bw-help-wrap" data-wp-on--keydown="actions.handleHelpKeyDown" data-wp-on--focusout="actions.handleHelpFocusOut">
1235        <button class="bw-help-toggle" data-wp-on--click="actions.toggleHelp" title="<?php esc_attr_e( 'Tips', 'jetpack-mu-wpcom' ); ?>" aria-label="<?php esc_attr_e( 'Tips', 'jetpack-mu-wpcom' ); ?>"><span class="bw-help-i" aria-hidden="true">i</span></button>
1236        <div class="bw-help-popover" hidden data-wp-bind--hidden="!state.showHelp">
1237            <div class="bw-help-note">
1238                <p class="bw-help-note-text">
1239                <?php
1240                echo wp_kses(
1241                    sprintf(
1242                        /* translators: %1$s and %2$s are the opening and closing tags of a link to the Write editor guide. */
1243                        __( 'You’re using %1$sWrite%2$s, a simple editor for writing.', 'jetpack-mu-wpcom' ),
1244                        '<a class="bw-help-note-guide" data-target="wpcom-help-center" href="https://wordpress.com/support/editors/write-editor/" target="_blank" rel="noopener noreferrer">',
1245                        '</a>'
1246                    ),
1247                    array(
1248                        'a' => array(
1249                            'class'       => array(),
1250                            'data-target' => array(),
1251                            'href'        => array(),
1252                            'target'      => array(),
1253                            'rel'         => array(),
1254                        ),
1255                    )
1256                );
1257                ?>
1258                </p>
1259                <p class="bw-help-note-switch">
1260                <?php
1261                echo wp_kses(
1262                    sprintf(
1263                        /* translators: %1$s and %2$s are the opening and closing tags of a button reading "Block editor". */
1264                        __( 'Use the %1$sBlock editor%2$s instead.', 'jetpack-mu-wpcom' ),
1265                        '<button type="button" class="bw-help-note-button" data-wp-on--click="actions.switchToBlockEditor">',
1266                        '</button>'
1267                    ),
1268                    array(
1269                        'button' => array(
1270                            'type'              => array(),
1271                            'class'             => array(),
1272                            'data-wp-on--click' => array(),
1273                        ),
1274                    )
1275                );
1276                ?>
1277                </p>
1278            </div>
1279            <div class="bw-help-title"><?php esc_html_e( 'Tips', 'jetpack-mu-wpcom' ); ?></div>
1280            <div class="bw-help-row"><kbd>/</kbd><span><?php esc_html_e( 'Insert a heading, image, video, list, quote or divider', 'jetpack-mu-wpcom' ); ?></span></div>
1281            <div class="bw-help-row"><kbd>Ctrl+B</kbd><span><?php esc_html_e( 'Bold', 'jetpack-mu-wpcom' ); ?></span></div>
1282            <div class="bw-help-row"><kbd>Ctrl+I</kbd><span><?php esc_html_e( 'Italic', 'jetpack-mu-wpcom' ); ?></span></div>
1283            <div class="bw-help-row"><kbd>Ctrl+K</kbd><span><?php esc_html_e( 'Insert link', 'jetpack-mu-wpcom' ); ?></span></div>
1284            <div class="bw-help-row"><kbd>Ctrl+S</kbd><span><?php esc_html_e( 'Save', 'jetpack-mu-wpcom' ); ?></span></div>
1285            <div class="bw-help-row"><kbd>Tab</kbd><span><?php esc_html_e( 'Navigate slash menu options', 'jetpack-mu-wpcom' ); ?></span></div>
1286            <div class="bw-help-row"><kbd>Shift+Tab</kbd><span><?php esc_html_e( 'Focus formatting toolbar', 'jetpack-mu-wpcom' ); ?></span></div>
1287            <div class="bw-help-row"><kbd>#tag</kbd><span><?php esc_html_e( 'A line containing only #tags assigns them to the post on save', 'jetpack-mu-wpcom' ); ?></span></div>
1288            <a class="bw-help-link" data-target="wpcom-help-center" href="https://wordpress.com/support/editors/write-editor/" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Read the Write editor guide', 'jetpack-mu-wpcom' ); ?> <span aria-hidden="true">&#8599;</span></a>
1289        </div>
1290        </div><!-- /.bw-help-wrap -->
1291        <span class="bw-status" data-wp-text="state.displayStatus"></span>
1292        <?php
1293        // Screen-reader announcement for transient status (saving/publishing,
1294        // "Please write something" validation, save/publish errors). Bound to
1295        // state.message only — not state.displayStatus — so the title mirror that
1296        // the visible .bw-status also carries isn't re-announced on every keystroke.
1297        ?>
1298        <span class="bw-visually-hidden" role="status" aria-live="polite" data-wp-text="state.message"></span>
1299        <div class="bw-topbar-actions">
1300            <button
1301                class="bw-btn bw-btn-draft"
1302                data-wp-on--click="actions.saveDraft"
1303                data-wp-bind--disabled="state.isSaving"
1304                data-wp-bind--hidden="state.isPublishedPost"
1305                <?php echo 'publish' === $post_status ? 'hidden' : ''; ?>
1306            ><?php esc_html_e( 'Save draft', 'jetpack-mu-wpcom' ); ?></button>
1307            <button
1308                class="bw-btn bw-btn-publish"
1309                data-wp-on--click="actions.publish"
1310                data-wp-bind--disabled="state.isSaving"
1311            ><?php echo 'publish' === $post_status ? esc_html__( 'Update', 'jetpack-mu-wpcom' ) : esc_html__( 'Publish', 'jetpack-mu-wpcom' ); ?></button>
1312            <div class="bw-more-wrap" data-wp-on--keydown="actions.handleMoreMenuKeyDown" data-wp-on--focusout="actions.handleMoreMenuFocusOut">
1313                <button
1314                    class="bw-more-toggle"
1315                    aria-haspopup="menu"
1316                    aria-expanded="false"
1317                    data-wp-bind--aria-expanded="state.showMoreMenu"
1318                    data-wp-on--click="actions.toggleMoreMenu"
1319                    title="<?php esc_attr_e( 'More options', 'jetpack-mu-wpcom' ); ?>"
1320                    aria-label="<?php esc_attr_e( 'More options', 'jetpack-mu-wpcom' ); ?>"
1321                ><span class="bw-more-dots"><?php wpcom_write_icon( 'kebab' ); ?></span></button>
1322                <div class="bw-more-menu" role="menu" aria-label="<?php esc_attr_e( 'More options', 'jetpack-mu-wpcom' ); ?>" hidden data-wp-bind--hidden="!state.showMoreMenu">
1323                    <button
1324                        class="bw-more-menu-item bw-more-save-draft"
1325                        role="menuitem"
1326                        tabindex="-1"
1327                        data-wp-on--click="actions.saveDraftFromMenu"
1328                        data-wp-bind--hidden="state.isPublishedPost"
1329                        <?php echo 'publish' === $post_status ? 'hidden' : ''; ?>
1330                    ><?php esc_html_e( 'Save draft', 'jetpack-mu-wpcom' ); ?></button>
1331                    <button
1332                        class="bw-more-menu-item"
1333                        role="menuitem"
1334                        tabindex="-1"
1335                        data-wp-on--click="actions.openInBlockEditor"
1336                    ><?php esc_html_e( 'Open in Block editor', 'jetpack-mu-wpcom' ); ?></button>
1337                    <button
1338                        class="bw-more-menu-item"
1339                        role="menuitem"
1340                        tabindex="-1"
1341                        data-wp-on--click="actions.previewPost"
1342                    ><?php esc_html_e( 'Preview', 'jetpack-mu-wpcom' ); ?></button>
1343                    <button
1344                        class="bw-more-menu-item"
1345                        role="menuitem"
1346                        tabindex="-1"
1347                        data-wp-on--click="actions.openPostPicker"
1348                    ><?php esc_html_e( 'Open post', 'jetpack-mu-wpcom' ); ?></button>
1349                </div>
1350            </div>
1351        </div>
1352    </header>
1353
1354    <!-- Recovery banner -->
1355    <div class="bw-recovery-banner" hidden data-wp-bind--hidden="!state.showRecoveryBanner">
1356        <span class="bw-recovery-text"><?php esc_html_e( 'You have a recent draft — continue editing?', 'jetpack-mu-wpcom' ); ?></span>
1357        <button class="bw-recovery-btn" data-wp-on--click="actions.resumeDraft"><?php esc_html_e( 'Resume editing', 'jetpack-mu-wpcom' ); ?></button>
1358        <button class="bw-recovery-dismiss" data-wp-on--click="actions.dismissRecovery" aria-label="<?php esc_attr_e( 'Dismiss draft recovery notice', 'jetpack-mu-wpcom' ); ?>">&times;</button>
1359    </div>
1360
1361    <!-- First-visit note. Sits after the recovery banner, which pushes it down. -->
1362    <div
1363        class="bw-editor-note"
1364        role="dialog"
1365        aria-label="<?php esc_attr_e( 'About the Write editor', 'jetpack-mu-wpcom' ); ?>"
1366        aria-describedby="bw-editor-note-text"
1367        tabindex="-1"
1368        hidden
1369        data-wp-bind--hidden="!state.showEditorNote"
1370        data-wp-on--keydown="actions.handleEditorNoteKeyDown"
1371    >
1372        <p class="bw-editor-note-text" id="bw-editor-note-text">
1373        <?php
1374        echo wp_kses(
1375            sprintf(
1376                /* translators: %1$s and %2$s are the opening and closing tags of a link to the Write editor guide. */
1377                __( 'You’re using %1$sWrite%2$s, a simple editor for writing.', 'jetpack-mu-wpcom' ),
1378                '<a class="bw-editor-note-guide" data-target="wpcom-help-center" href="https://wordpress.com/support/editors/write-editor/" target="_blank" rel="noopener noreferrer">',
1379                '</a>'
1380            ),
1381            array(
1382                'a' => array(
1383                    'class'       => array(),
1384                    'data-target' => array(),
1385                    'href'        => array(),
1386                    'target'      => array(),
1387                    'rel'         => array(),
1388                ),
1389            )
1390        );
1391        ?>
1392        </p>
1393        <div class="bw-editor-note-actions">
1394            <button class="bw-editor-note-secondary" data-wp-on--click="actions.openInBlockEditorFromNote"><?php esc_html_e( 'Use the Block editor', 'jetpack-mu-wpcom' ); ?></button>
1395            <button class="bw-editor-note-ok" data-wp-on--click="actions.dismissEditorNote"><?php esc_html_e( 'Got it', 'jetpack-mu-wpcom' ); ?></button>
1396        </div>
1397    </div>
1398
1399    <!-- Persistent formatting toolbar -->
1400    <div
1401        class="bw-toolbar"
1402        role="toolbar"
1403        aria-label="<?php esc_attr_e( 'Formatting', 'jetpack-mu-wpcom' ); ?>"
1404        data-wp-on--mousedown="actions.preventToolbarBlur"
1405        data-wp-on--keydown="actions.handleToolbarKeyDown"
1406    >
1407        <div class="bw-toolbar-scroll">
1408            <!-- Undo / Redo -->
1409            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Undo', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.undo" data-wp-bind--disabled="!state.canUndo" title="<?php esc_attr_e( 'Undo', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'undo' ); ?></button>
1410            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Redo', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.redo" data-wp-bind--disabled="!state.canRedo" title="<?php esc_attr_e( 'Redo', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'redo' ); ?></button>
1411            <span class="bw-tool-divider"></span>
1412            <!-- Heading dropdown -->
1413            <div class="bw-tool-dropdown-wrap">
1414                <button class="bw-tool bw-tool-heading-toggle" aria-label="<?php esc_attr_e( 'Text style', 'jetpack-mu-wpcom' ); ?>" aria-haspopup="menu" aria-expanded="false" tabindex="0" data-wp-bind--aria-expanded="state.showHeadingMenu" data-wp-on--click="actions.toggleHeadingMenu" data-wp-class--bw-tool-active="state.formatHeading" title="<?php esc_attr_e( 'Text style', 'jetpack-mu-wpcom' ); ?>">
1415                    <span class="bw-tool-label" data-wp-text="state.headingLabel"><?php esc_html_e( 'Normal', 'jetpack-mu-wpcom' ); ?></span>
1416                    <span class="bw-tool-caret"><?php wpcom_write_icon( 'chevron-down' ); ?></span>
1417                </button>
1418                <div class="bw-heading-menu" role="menu" aria-label="<?php esc_attr_e( 'Text style', 'jetpack-mu-wpcom' ); ?>" hidden data-wp-bind--hidden="!state.showHeadingMenu" data-wp-on--keydown="actions.handleSubmenuKeyDown">
1419                    <button class="bw-heading-option" role="menuitem" tabindex="-1" data-wp-on--click="actions.setHeadingNormal" data-wp-on--mousedown="actions.preventToolbarBlur"><span><?php esc_html_e( 'Normal', 'jetpack-mu-wpcom' ); ?></span></button>
1420                    <button class="bw-heading-option bw-heading-option-h2" role="menuitem" tabindex="-1" data-wp-on--click="actions.setHeadingH2" data-wp-on--mousedown="actions.preventToolbarBlur"><span><?php esc_html_e( 'Heading 2', 'jetpack-mu-wpcom' ); ?></span></button>
1421                    <button class="bw-heading-option bw-heading-option-h3" role="menuitem" tabindex="-1" data-wp-on--click="actions.setHeadingH3" data-wp-on--mousedown="actions.preventToolbarBlur"><span><?php esc_html_e( 'Heading 3', 'jetpack-mu-wpcom' ); ?></span></button>
1422                </div>
1423            </div>
1424            <span class="bw-tool-divider"></span>
1425            <!-- Inline formatting -->
1426            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Bold', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.formatBold" data-wp-class--bw-tool-active="state.formatBold" title="<?php esc_attr_e( 'Bold', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'bold' ); ?></button>
1427            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Italic', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.formatItalic" data-wp-class--bw-tool-active="state.formatItalic" title="<?php esc_attr_e( 'Italic', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'italic' ); ?></button>
1428            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Underline', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.formatUnderline" data-wp-class--bw-tool-active="state.formatUnderline" title="<?php esc_attr_e( 'Underline', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'underline' ); ?></button>
1429            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Strikethrough', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.formatStrikethrough" data-wp-class--bw-tool-active="state.formatStrikethrough" title="<?php esc_attr_e( 'Strikethrough', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'strikethrough' ); ?></button>
1430            <!-- Text color -->
1431            <div class="bw-tool-dropdown-wrap">
1432                <button class="bw-tool" aria-label="<?php esc_attr_e( 'Text color', 'jetpack-mu-wpcom' ); ?>" aria-haspopup="menu" aria-expanded="false" tabindex="-1" data-wp-bind--aria-expanded="state.showTextColorMenu" data-wp-on--click="actions.toggleTextColorMenu" title="<?php esc_attr_e( 'Text color', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'text-color' ); ?></button>
1433                <div class="bw-color-menu" role="menu" aria-label="<?php esc_attr_e( 'Text color', 'jetpack-mu-wpcom' ); ?>" hidden data-wp-bind--hidden="!state.showTextColorMenu" data-wp-on--mousedown="actions.preventToolbarBlur" data-wp-on--keydown="actions.handleSubmenuKeyDown">
1434                    <button class="bw-color-swatch" role="menuitem" tabindex="-1" style="background:#1a1a1a;" aria-label="<?php esc_attr_e( 'Default', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.setTextColorDefault" title="<?php esc_attr_e( 'Default', 'jetpack-mu-wpcom' ); ?>"></button>
1435                    <button class="bw-color-swatch" role="menuitem" tabindex="-1" style="background:#d63638;" aria-label="<?php esc_attr_e( 'Red', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.setTextColorRed" title="<?php esc_attr_e( 'Red', 'jetpack-mu-wpcom' ); ?>"></button>
1436                    <button class="bw-color-swatch" role="menuitem" tabindex="-1" style="background:#2171b1;" aria-label="<?php esc_attr_e( 'Blue', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.setTextColorBlue" title="<?php esc_attr_e( 'Blue', 'jetpack-mu-wpcom' ); ?>"></button>
1437                    <button class="bw-color-swatch" role="menuitem" tabindex="-1" style="background:#00a32a;" aria-label="<?php esc_attr_e( 'Green', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.setTextColorGreen" title="<?php esc_attr_e( 'Green', 'jetpack-mu-wpcom' ); ?>"></button>
1438                    <button class="bw-color-swatch" role="menuitem" tabindex="-1" style="background:#dba617;" aria-label="<?php esc_attr_e( 'Yellow', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.setTextColorYellow" title="<?php esc_attr_e( 'Yellow', 'jetpack-mu-wpcom' ); ?>"></button>
1439                    <button class="bw-color-swatch" role="menuitem" tabindex="-1" style="background:#8c5db0;" aria-label="<?php esc_attr_e( 'Purple', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.setTextColorPurple" title="<?php esc_attr_e( 'Purple', 'jetpack-mu-wpcom' ); ?>"></button>
1440                </div>
1441            </div>
1442            <span class="bw-tool-divider"></span>
1443            <!-- Alignment -->
1444            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Align left', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.alignLeft" data-wp-class--bw-tool-active="state.formatAlignLeft" data-wp-bind--disabled="state.insideList" title="<?php esc_attr_e( 'Align left', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'align-left' ); ?></button>
1445            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Align center', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.alignCenter" data-wp-class--bw-tool-active="state.formatAlignCenter" data-wp-bind--disabled="state.insideList" title="<?php esc_attr_e( 'Align center', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'align-center' ); ?></button>
1446            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Align right', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.alignRight" data-wp-class--bw-tool-active="state.formatAlignRight" data-wp-bind--disabled="state.insideList" title="<?php esc_attr_e( 'Align right', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'align-right' ); ?></button>
1447            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Justify', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.alignJustify" data-wp-class--bw-tool-active="state.formatAlignJustify" data-wp-bind--disabled="state.cannotJustify" title="<?php esc_attr_e( 'Justify', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'align-justify' ); ?></button>
1448            <span class="bw-tool-divider"></span>
1449            <!-- Lists -->
1450            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Bulleted list', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.formatUList" data-wp-class--bw-tool-active="state.formatUList" title="<?php esc_attr_e( 'Bulleted list', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'list-bullets' ); ?></button>
1451            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Numbered list', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.formatOList" data-wp-class--bw-tool-active="state.formatOList" title="<?php esc_attr_e( 'Numbered list', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'list-numbered' ); ?></button>
1452            <span class="bw-tool-divider"></span>
1453            <!-- Block-level -->
1454            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Link', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.toggleLinkInput" data-wp-class--bw-tool-active="state.showLinkInput" title="<?php esc_attr_e( 'Link', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'link' ); ?></button>
1455            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Quote', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.formatQuote" data-wp-class--bw-tool-active="state.formatQuote" title="<?php esc_attr_e( 'Quote', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'quote' ); ?></button>
1456            <button class="bw-tool" aria-label="<?php esc_attr_e( 'Image', 'jetpack-mu-wpcom' ); ?>" tabindex="-1" data-wp-on--click="actions.openImageModal" data-wp-bind--disabled="state.insideList" title="<?php esc_attr_e( 'Image', 'jetpack-mu-wpcom' ); ?>"><?php wpcom_write_icon( 'image' ); ?></button>
1457        </div>
1458    </div>
1459
1460    <!-- Link input popover -->
1461    <div class="bw-link-popover" hidden data-wp-bind--hidden="!state.showLinkInput" data-wp-on--mousedown="actions.preventToolbarBlur">
1462        <input
1463            type="url"
1464            class="bw-link-input"
1465            placeholder="<?php esc_attr_e( 'Paste or type a link...', 'jetpack-mu-wpcom' ); ?>"
1466            data-wp-bind--value="state.linkUrl"
1467            data-wp-on--input="actions.updateLinkUrl"
1468            data-wp-on--keydown="actions.handleLinkKeyDown"
1469        />
1470        <button class="bw-link-apply" data-wp-on--click="actions.applyLink"><?php esc_html_e( 'Apply', 'jetpack-mu-wpcom' ); ?></button>
1471        <button class="bw-link-remove" data-wp-on--click="actions.removeLink">&times;</button>
1472    </div>
1473
1474    <!-- Writing area -->
1475    <main class="bw-main" data-wp-on--click="actions.handleMainClick">
1476        <div class="bw-editor" data-wp-bind--inert="state.unsupportedWarning">
1477
1478            <?php if ( $show_cat_row ) : ?>
1479            <!-- Category selector — only shown when site has 2+ used categories -->
1480            <div class="bw-meta" data-wp-on--focusout="actions.handleCatFocusOut">
1481                <button
1482                    class="bw-meta-cat-btn"
1483                    aria-haspopup="listbox"
1484                    aria-expanded="false"
1485                    aria-controls="bw-cat-dropdown"
1486                    data-wp-bind--aria-expanded="state.showCatDropdown"
1487                    data-wp-on--click="actions.toggleCatDropdown"
1488                    data-wp-on--keydown="actions.handleCatBtnKeyDown"
1489                >
1490                    <span
1491                        class="bw-meta-cat-label"
1492                        data-placeholder="<?php esc_attr_e( 'Select a category', 'jetpack-mu-wpcom' ); ?>"
1493                        data-wp-text="state.catLabel"
1494                    ><?php echo esc_html( $cat_label ); ?></span>
1495                    <span class="bw-meta-cat-caret" aria-hidden="true">&#9662;</span>
1496                </button>
1497                <div
1498                    class="bw-meta-dropdown"
1499                    id="bw-cat-dropdown"
1500                    role="listbox"
1501                    aria-multiselectable="true"
1502                    hidden
1503                    data-wp-bind--hidden="!state.showCatDropdown"
1504                    data-wp-on--keydown="actions.handleCatDropdownKeyDown"
1505                >
1506                    <?php foreach ( $categories_data as $i => $cat ) : ?>
1507                    <button
1508                        class="bw-meta-dropdown-item<?php echo $cat['selected'] ? ' bw-meta-dropdown-item--selected' : ''; ?>"
1509                        role="option"
1510                        aria-selected="<?php echo $cat['selected'] ? 'true' : 'false'; ?>"
1511                        tabindex="-1"
1512                        data-cat-index="<?php echo (int) $i; ?>"
1513                        data-cat-name="<?php echo esc_attr( $cat['name'] ); ?>"
1514                        data-wp-on--click="actions.handleCatDropdownClick"
1515                    ><?php echo esc_html( $cat['name'] ); ?></button>
1516                    <?php endforeach; ?>
1517                </div>
1518            </div><!-- /.bw-meta -->
1519            <?php endif; ?>
1520
1521            <textarea
1522                class="bw-title"
1523                placeholder="<?php esc_attr_e( 'Title', 'jetpack-mu-wpcom' ); ?>"
1524                rows="1"
1525                data-wp-on--input="actions.updateTitle"
1526                data-wp-on--keydown="actions.handleTitleKeyDown"
1527                autocomplete="off"
1528            ><?php echo esc_textarea( $edit_title ); ?></textarea>
1529            <div class="bw-separator"></div>
1530            <div
1531                class="bw-content<?php echo $edit_content ? '' : ' bw-is-empty'; ?>"
1532                data-wp-watch="callbacks.syncComboboxAria"
1533                data-wp-on--mouseup="actions.checkFormatting"
1534                data-wp-on--keyup="actions.checkFormatting"
1535                data-wp-on--click="actions.handleContentClick"
1536                data-wp-on--input="actions.repairStructure"
1537                data-wp-on--keydown="actions.handleKeyDown"
1538                data-wp-on--beforeinput="actions.handleBeforeInput"
1539                data-wp-on--dragstart="actions.handleEditorDragStart"
1540                data-wp-on--dragover="actions.handleEditorDragOver"
1541                data-wp-on--dragleave="actions.handleEditorDragLeave"
1542                data-wp-on--drop="actions.handleEditorDrop"
1543                data-wp-on--dragend="actions.handleEditorDragEnd"
1544                data-placeholder="<?php esc_attr_e( 'Tell your story...', 'jetpack-mu-wpcom' ); ?>"
1545            ><div
1546                class="bw-content-inner"
1547                contenteditable="true"
1548                role="combobox"
1549                aria-label="<?php esc_attr_e( 'Post content', 'jetpack-mu-wpcom' ); ?>"
1550                aria-autocomplete="list"
1551                aria-haspopup="listbox"
1552                aria-expanded="false"
1553                aria-controls="bw-slash-menu"
1554                data-wp-ignore
1555            >
1556            <?php
1557            if ( $edit_content ) {
1558                // Convert block-editor alignment classes to inline styles so
1559                // convertToBlocks() can read them via node.style.textAlign.
1560                $edit_content = wpcom_write_alignment_classes_to_inline( $edit_content );
1561                // Convert Gutenberg <mark class="has-inline-color"> to
1562                // <span style="color:#hex"> so foreColor can interact with
1563                // existing colors.  Done before kses so the simpler span+hex
1564                // format passes through without needing a custom kses filter.
1565                $edit_content = wpcom_write_inline_color_marks_to_spans( $edit_content );
1566                // Sanitize through wp_kses_post — video embed tokens (HTML comments)
1567                // pass through untouched, then we swap them for the real iframe HTML.
1568                // This avoids the pre_kses filter that converts iframes to shortcodes.
1569                $sanitized = wp_kses_post( $edit_content );
1570                if ( $video_placeholders ) {
1571                    $sanitized = str_replace( array_keys( $video_placeholders ), array_values( $video_placeholders ), $sanitized );
1572                }
1573                echo $sanitized; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sanitized by wp_kses_post; iframe HTML is self-constructed with esc_url/esc_attr.
1574            } else {
1575                echo '<p><br></p>';
1576            }
1577            ?>
1578            </div></div>
1579        </div>
1580    </main>
1581
1582    <!-- Image insert modal: centered, blocking overlay for picking a new image. -->
1583    <div class="bw-image-overlay" hidden data-wp-bind--hidden="!state.showImageInsertOverlay" data-wp-on--pointerdown="actions.handleOverlayPointerDown" data-wp-on--keydown="actions.handleImageModalKeyDown" data-wp-on--dragover="actions.handleOverlayDragOver" data-wp-on--drop="actions.handleOverlayDrop">
1584        <div class="bw-image-modal" role="dialog" aria-modal="true" aria-label="<?php esc_attr_e( 'Add an image', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.stopPropagation" data-wp-on--dragover="actions.handleOverlayDragOver" data-wp-on--drop="actions.handleOverlayDrop">
1585            <h3><?php esc_html_e( 'Add an image', 'jetpack-mu-wpcom' ); ?></h3>
1586            <label class="bw-upload-zone" id="bw-upload-zone" data-wp-on--dragover="actions.handleDragOver" data-wp-on--dragleave="actions.handleDragLeave" data-wp-on--drop="actions.handleDrop">
1587                <span class="bw-upload-label"><?php esc_html_e( 'Drop a file or click to upload', 'jetpack-mu-wpcom' ); ?></span>
1588                <span class="bw-upload-saving" style="display:none;"><?php esc_html_e( 'Uploading...', 'jetpack-mu-wpcom' ); ?></span>
1589                <input type="file" accept="image/*" data-wp-on--change="actions.uploadImage" class="bw-visually-hidden" />
1590            </label>
1591            <input
1592                type="text"
1593                class="bw-image-url-input bw-image-alt-input"
1594                placeholder="<?php esc_attr_e( 'Alt text (describe the image)...', 'jetpack-mu-wpcom' ); ?>"
1595                data-wp-on--input="actions.updateImageAlt"
1596                data-wp-bind--value="state.imageAlt"
1597            />
1598            <label class="bw-featured-toggle">
1599                <input type="checkbox" data-wp-on--change="actions.toggleFeaturedImage" data-wp-bind--checked="state.setAsFeatured" />
1600                <span><?php esc_html_e( 'Set as featured image', 'jetpack-mu-wpcom' ); ?></span>
1601            </label>
1602            <button class="bw-btn bw-btn-publish bw-insert-image-btn" data-wp-on--click="actions.insertImageFromUrl"><?php esc_html_e( 'Insert image', 'jetpack-mu-wpcom' ); ?></button>
1603
1604            <!-- Secondary sources: collapsed by default. -->
1605            <div class="bw-source-expanders">
1606                <div class="bw-source-expander">
1607                    <button
1608                        type="button"
1609                        class="bw-source-trigger"
1610                        aria-controls="bw-library-section"
1611                        data-wp-bind--aria-expanded="state.showLibraryPicker"
1612                        data-wp-on--click="actions.toggleLibraryPicker"
1613                    >
1614                        <span class="bw-source-chevron" aria-hidden="true"></span>
1615                        <?php esc_html_e( 'From your library', 'jetpack-mu-wpcom' ); ?>
1616                    </button>
1617                    <div id="bw-library-section" class="bw-library-section" hidden data-wp-bind--hidden="!state.showLibraryPicker">
1618                        <label class="bw-visually-hidden" for="bw-library-search"><?php esc_html_e( 'Search your media library', 'jetpack-mu-wpcom' ); ?></label>
1619                        <input
1620                            id="bw-library-search"
1621                            type="search"
1622                            class="bw-library-search"
1623                            placeholder="<?php esc_attr_e( 'Search your library…', 'jetpack-mu-wpcom' ); ?>"
1624                            autocomplete="off"
1625                            data-wp-on--input="actions.searchLibrary"
1626                            data-wp-bind--value="state.librarySearch"
1627                        />
1628                        <div
1629                            id="bw-library-grid"
1630                            class="bw-library-strip"
1631                            role="group"
1632                            aria-label="<?php esc_attr_e( 'Your media library', 'jetpack-mu-wpcom' ); ?>"
1633                            data-wp-on--click="actions.selectLibraryImage"
1634                        ></div>
1635                        <div class="bw-library-status" role="status" aria-live="polite" data-wp-text="state.libraryStatus"></div>
1636                    </div>
1637                </div>
1638                <div class="bw-source-expander">
1639                    <button
1640                        type="button"
1641                        class="bw-source-trigger"
1642                        aria-controls="bw-url-section"
1643                        data-wp-bind--aria-expanded="state.showUrlInput"
1644                        data-wp-on--click="actions.toggleUrlInput"
1645                    >
1646                        <span class="bw-source-chevron" aria-hidden="true"></span>
1647                        <?php esc_html_e( 'Paste an image URL', 'jetpack-mu-wpcom' ); ?>
1648                    </button>
1649                    <div id="bw-url-section" class="bw-url-section" hidden data-wp-bind--hidden="!state.showUrlInput">
1650                        <input
1651                            type="url"
1652                            class="bw-image-url-input"
1653                            placeholder="<?php esc_attr_e( 'https://…', 'jetpack-mu-wpcom' ); ?>"
1654                            data-wp-on--input="actions.updateImageUrl"
1655                            data-wp-bind--value="state.imageUrl"
1656                        />
1657                    </div>
1658                </div>
1659            </div>
1660
1661        </div>
1662    </div>
1663
1664    <!-- Image edit panel: non-modal, docked bottom-right. Changes apply
1665        live to the figure so the rest of the editor stays visible. -->
1666    <div
1667        class="bw-image-edit-panel"
1668        hidden
1669        data-wp-bind--hidden="!state.isEditMode"
1670        role="dialog"
1671        aria-modal="false"
1672        aria-labelledby="bw-edit-panel-title"
1673        data-wp-watch="callbacks.syncEditImageModalRadios"
1674    >
1675        <div class="bw-edit-panel-header">
1676            <h3 id="bw-edit-panel-title" class="bw-edit-panel-title"><?php esc_html_e( 'Edit image', 'jetpack-mu-wpcom' ); ?></h3>
1677            <button class="bw-edit-panel-close" type="button" data-wp-on--click="actions.closeImageModal" aria-label="<?php esc_attr_e( 'Close', 'jetpack-mu-wpcom' ); ?>" title="<?php esc_attr_e( 'Close', 'jetpack-mu-wpcom' ); ?>">&times;</button>
1678        </div>
1679
1680        <label class="bw-edit-label" for="bw-edit-alt"><?php esc_html_e( 'Alt text', 'jetpack-mu-wpcom' ); ?></label>
1681        <input
1682            id="bw-edit-alt"
1683            type="text"
1684            class="bw-image-url-input"
1685            placeholder="<?php esc_attr_e( 'Describe this image…', 'jetpack-mu-wpcom' ); ?>"
1686            data-wp-on--input="actions.updateImageAlt"
1687            data-wp-bind--value="state.imageAlt"
1688        />
1689
1690        <div class="bw-edit-section" hidden data-wp-bind--hidden="!state.editingImageHasMediaId">
1691            <div class="bw-edit-label"><?php esc_html_e( 'Size', 'jetpack-mu-wpcom' ); ?></div>
1692            <div class="bw-edit-radios" role="radiogroup" aria-label="<?php esc_attr_e( 'Image size', 'jetpack-mu-wpcom' ); ?>" data-wp-on--keydown="actions.handleEditRadiogroupKeyDown">
1693                <button type="button" class="bw-edit-size-option" role="radio" aria-checked="false" tabindex="-1" value="thumbnail" data-wp-on--click="actions.setEditImageSize"><?php esc_html_e( 'Thumbnail', 'jetpack-mu-wpcom' ); ?></button>
1694                <button type="button" class="bw-edit-size-option" role="radio" aria-checked="false" tabindex="-1" value="medium" data-wp-on--click="actions.setEditImageSize"><?php esc_html_e( 'Medium', 'jetpack-mu-wpcom' ); ?></button>
1695                <button type="button" class="bw-edit-size-option" role="radio" aria-checked="false" tabindex="-1" value="large" data-wp-on--click="actions.setEditImageSize"><?php esc_html_e( 'Large', 'jetpack-mu-wpcom' ); ?></button>
1696                <button type="button" class="bw-edit-size-option" role="radio" aria-checked="false" tabindex="-1" value="full" data-wp-on--click="actions.setEditImageSize"><?php esc_html_e( 'Full', 'jetpack-mu-wpcom' ); ?></button>
1697            </div>
1698        </div>
1699
1700        <div class="bw-edit-section">
1701            <div class="bw-edit-label"><?php esc_html_e( 'Alignment', 'jetpack-mu-wpcom' ); ?></div>
1702            <div class="bw-edit-radios" role="radiogroup" aria-label="<?php esc_attr_e( 'Image alignment', 'jetpack-mu-wpcom' ); ?>" data-wp-on--keydown="actions.handleEditRadiogroupKeyDown">
1703                <button type="button" class="bw-edit-align-option" role="radio" aria-checked="false" tabindex="-1" value="left" data-wp-on--click="actions.setEditImageAlign"><?php esc_html_e( 'Left', 'jetpack-mu-wpcom' ); ?></button>
1704                <button type="button" class="bw-edit-align-option" role="radio" aria-checked="false" tabindex="-1" value="center" data-wp-on--click="actions.setEditImageAlign"><?php esc_html_e( 'Center', 'jetpack-mu-wpcom' ); ?></button>
1705                <button type="button" class="bw-edit-align-option" role="radio" aria-checked="false" tabindex="-1" value="right" data-wp-on--click="actions.setEditImageAlign"><?php esc_html_e( 'Right', 'jetpack-mu-wpcom' ); ?></button>
1706            </div>
1707        </div>
1708
1709        <label class="bw-featured-toggle" hidden data-wp-bind--hidden="!state.editingImageHasMediaId">
1710            <input type="checkbox" data-wp-on--change="actions.toggleFeaturedImage" data-wp-bind--checked="state.setAsFeatured" />
1711            <span><?php esc_html_e( 'Set as featured image', 'jetpack-mu-wpcom' ); ?></span>
1712        </label>
1713
1714        <button class="bw-btn bw-btn-publish bw-edit-panel-done" type="button" data-wp-on--click="actions.closeImageModal"><?php esc_html_e( 'Done', 'jetpack-mu-wpcom' ); ?></button>
1715    </div>
1716
1717    <!-- Leave confirmation — matches @wordpress/components ConfirmDialog -->
1718    <div class="bw-leave-overlay" hidden data-wp-bind--hidden="!state.showLeaveConfirm" data-wp-on--click="actions.cancelLeave" data-wp-on--keydown="actions.handleLeaveModalKeyDown">
1719        <div class="bw-leave-modal" role="dialog" aria-modal="true" aria-label="<?php esc_attr_e( 'Unsaved changes', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.stopPropagation">
1720            <p><?php esc_html_e( 'Do you want to save your changes?', 'jetpack-mu-wpcom' ); ?></p>
1721            <div class="bw-leave-actions">
1722                <button class="bw-leave-cancel" data-wp-on--click="actions.cancelLeave"><?php esc_html_e( 'Cancel', 'jetpack-mu-wpcom' ); ?></button>
1723                <button class="bw-leave-confirm" data-wp-on--click="actions.confirmLeave"><?php esc_html_e( "Don't save", 'jetpack-mu-wpcom' ); ?></button>
1724                <button class="bw-leave-save" data-wp-on--click="actions.saveAndLeave"><?php esc_html_e( 'Save', 'jetpack-mu-wpcom' ); ?></button>
1725            </div>
1726        </div>
1727    </div>
1728
1729    <!-- Unsupported content warning -->
1730    <div class="bw-unsupported-overlay" hidden data-wp-bind--hidden="!state.unsupportedWarning" data-wp-on--keydown="actions.handleUnsupportedKeyDown">
1731        <div class="bw-unsupported-modal" role="dialog" aria-modal="true" aria-label="<?php esc_attr_e( 'Unsupported formatting', 'jetpack-mu-wpcom' ); ?>" data-wp-bind--aria-describedby="state.unsupportedDescId" data-wp-on--click="actions.stopPropagation">
1732            <p class="bw-unsupported-title"><?php esc_html_e( 'This post has formatting that Write can\'t preserve', 'jetpack-mu-wpcom' ); ?></p>
1733            <p class="bw-unsupported-desc" id="bw-unsupported-desc-classic" hidden data-wp-bind--hidden="!state.isClassicWarning"><?php esc_html_e( 'This post was created in the classic editor. Editing in Write would convert it to the block format, which may change some formatting.', 'jetpack-mu-wpcom' ); ?></p>
1734            <p class="bw-unsupported-desc" id="bw-unsupported-desc-block" hidden data-wp-bind--hidden="!state.isBlockEditorWarning"><?php esc_html_e( 'This post uses block editor features that the Write editor doesn\'t support. Editing here could lose some of that formatting.', 'jetpack-mu-wpcom' ); ?></p>
1735            <div class="bw-unsupported-actions">
1736                <button class="bw-unsupported-back" data-wp-on--click="actions.goBack"><?php esc_html_e( 'Go back', 'jetpack-mu-wpcom' ); ?></button>
1737                <button class="bw-unsupported-open-editor" hidden data-wp-bind--hidden="!state.isClassicWarning" data-wp-on--click="actions.openEditor"><?php esc_html_e( 'Open in Classic Editor', 'jetpack-mu-wpcom' ); ?></button>
1738                <button class="bw-unsupported-open-editor" hidden data-wp-bind--hidden="!state.isBlockEditorWarning" data-wp-on--click="actions.openEditor"><?php esc_html_e( 'Open in Block editor', 'jetpack-mu-wpcom' ); ?></button>
1739            </div>
1740        </div>
1741    </div>
1742
1743    <!-- Slash command menu -->
1744    <div class="bw-slash-menu" id="bw-slash-menu" role="listbox" aria-label="<?php esc_attr_e( 'Insert block', 'jetpack-mu-wpcom' ); ?>" hidden data-wp-bind--hidden="!state.showSlashMenu">
1745        <div class="bw-slash-item" id="bw-slash-opt-heading" role="option" aria-selected="false" data-action="heading" data-wp-on--click="actions.insertHeading" data-wp-on--mousedown="actions.preventToolbarBlur">
1746            <span class="bw-slash-icon" aria-hidden="true">H</span>
1747            <div><strong><?php esc_html_e( 'Heading', 'jetpack-mu-wpcom' ); ?></strong><span class="bw-slash-desc"><?php esc_html_e( 'Large section heading', 'jetpack-mu-wpcom' ); ?></span></div>
1748        </div>
1749        <div class="bw-slash-item" id="bw-slash-opt-image" role="option" aria-selected="false" data-action="image" data-wp-on--click="actions.insertImage" data-wp-on--mousedown="actions.preventToolbarBlur">
1750            <span class="bw-slash-icon" aria-hidden="true">&#9653;</span>
1751            <div><strong><?php esc_html_e( 'Image', 'jetpack-mu-wpcom' ); ?></strong><span class="bw-slash-desc"><?php esc_html_e( 'Upload or embed an image', 'jetpack-mu-wpcom' ); ?></span></div>
1752        </div>
1753        <div class="bw-slash-item" id="bw-slash-opt-quote" role="option" aria-selected="false" data-action="quote" data-wp-on--click="actions.insertQuote" data-wp-on--mousedown="actions.preventToolbarBlur">
1754            <span class="bw-slash-icon" aria-hidden="true">&ldquo;</span>
1755            <div><strong><?php esc_html_e( 'Quote', 'jetpack-mu-wpcom' ); ?></strong><span class="bw-slash-desc"><?php esc_html_e( 'Highlight a quote', 'jetpack-mu-wpcom' ); ?></span></div>
1756        </div>
1757        <div class="bw-slash-item" id="bw-slash-opt-bulleted-list" role="option" aria-selected="false" data-action="bulleted-list" data-wp-on--click="actions.insertBulletedList" data-wp-on--mousedown="actions.preventToolbarBlur">
1758            <span class="bw-slash-icon" aria-hidden="true">&bull;</span>
1759            <div><strong><?php esc_html_e( 'Bulleted list', 'jetpack-mu-wpcom' ); ?></strong><span class="bw-slash-desc"><?php esc_html_e( 'An unordered list', 'jetpack-mu-wpcom' ); ?></span></div>
1760        </div>
1761        <div class="bw-slash-item" id="bw-slash-opt-numbered-list" role="option" aria-selected="false" data-action="numbered-list" data-wp-on--click="actions.insertNumberedList" data-wp-on--mousedown="actions.preventToolbarBlur">
1762            <span class="bw-slash-icon" aria-hidden="true">1.</span>
1763            <div><strong><?php esc_html_e( 'Numbered list', 'jetpack-mu-wpcom' ); ?></strong><span class="bw-slash-desc"><?php esc_html_e( 'An ordered list', 'jetpack-mu-wpcom' ); ?></span></div>
1764        </div>
1765        <div class="bw-slash-item" id="bw-slash-opt-video" role="option" aria-selected="false" data-action="video" data-wp-on--click="actions.insertVideo" data-wp-on--mousedown="actions.preventToolbarBlur">
1766            <span class="bw-slash-icon" aria-hidden="true">&#9654;</span>
1767            <div><strong><?php esc_html_e( 'Video', 'jetpack-mu-wpcom' ); ?></strong><span class="bw-slash-desc"><?php esc_html_e( 'Embed a YouTube or Vimeo video', 'jetpack-mu-wpcom' ); ?></span></div>
1768        </div>
1769        <div class="bw-slash-item" id="bw-slash-opt-divider" role="option" aria-selected="false" data-action="divider" data-wp-on--click="actions.insertDivider" data-wp-on--mousedown="actions.preventToolbarBlur">
1770            <span class="bw-slash-icon" aria-hidden="true">&mdash;</span>
1771            <div><strong><?php esc_html_e( 'Divider', 'jetpack-mu-wpcom' ); ?></strong><span class="bw-slash-desc"><?php esc_html_e( 'A horizontal separator', 'jetpack-mu-wpcom' ); ?></span></div>
1772        </div>
1773    </div>
1774
1775    <!-- Video modal -->
1776    <div class="bw-image-overlay" hidden data-wp-bind--hidden="!state.showVideoModal" data-wp-on--pointerdown="actions.handleOverlayPointerDown" data-wp-on--keydown="actions.handleVideoModalKeyDown">
1777        <div class="bw-image-modal" role="dialog" aria-modal="true" aria-label="<?php esc_attr_e( 'Embed a video', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.stopPropagation">
1778            <h3><?php esc_html_e( 'Embed a video', 'jetpack-mu-wpcom' ); ?></h3>
1779            <input
1780                type="url"
1781                class="bw-image-url-input"
1782                placeholder="<?php esc_attr_e( 'Paste a YouTube or Vimeo URL...', 'jetpack-mu-wpcom' ); ?>"
1783                data-wp-on--input="actions.updateVideoUrl"
1784                data-wp-on--keydown="actions.handleVideoKeyDown"
1785                data-wp-bind--value="state.videoUrl"
1786            />
1787            <button class="bw-btn bw-btn-publish" data-wp-on--click="actions.insertVideoEmbed" style="width:100%;margin-top:12px;"><?php esc_html_e( 'Embed video', 'jetpack-mu-wpcom' ); ?></button>
1788        </div>
1789    </div>
1790
1791    <!-- Post picker modal -->
1792    <div class="bw-postpicker-overlay" hidden data-wp-bind--hidden="!state.showPostPicker" data-wp-on--pointerdown="actions.handlePostPickerOverlayClick" data-wp-on--keydown="actions.handlePostPickerKeyDown">
1793        <div class="bw-postpicker-modal" role="dialog" aria-modal="true" aria-label="<?php esc_attr_e( 'Open post', 'jetpack-mu-wpcom' ); ?>" data-wp-on--click="actions.stopPropagation">
1794            <h3 class="bw-postpicker-title"><?php esc_html_e( 'Open post', 'jetpack-mu-wpcom' ); ?></h3>
1795
1796            <p class="bw-postpicker-error" <?php echo $open_post_error ? '' : 'hidden'; ?> data-wp-bind--hidden="!state.openPostError" data-wp-text="state.openPostError"><?php echo esc_html( $open_post_error ); ?></p>
1797
1798            <?php if ( ! empty( $recent_drafts ) ) : ?>
1799            <p class="bw-postpicker-label"><?php esc_html_e( 'Last edited', 'jetpack-mu-wpcom' ); ?></p>
1800            <div class="bw-postpicker-list" role="listbox" aria-label="<?php esc_attr_e( 'Recent drafts', 'jetpack-mu-wpcom' ); ?>">
1801                <?php foreach ( $recent_drafts as $idx => $draft ) : ?>
1802                <button
1803                    class="bw-postpicker-item"
1804                    role="option"
1805                    tabindex="<?php echo 0 === $idx ? '0' : '-1'; ?>"
1806                    data-post-id="<?php echo (int) $draft['id']; ?>"
1807                    data-wp-on--click="actions.openPickedPost"
1808                >
1809                    <span class="bw-postpicker-item-title<?php echo empty( $draft['title'] ) ? ' bw-postpicker-item-untitled' : ''; ?>"><?php echo esc_html( $draft['title'] ? $draft['title'] : __( 'Untitled', 'jetpack-mu-wpcom' ) ); ?></span>
1810                    <span class="bw-postpicker-item-date" data-modified="<?php echo esc_attr( $draft['modified'] ); ?>"></span>
1811                </button>
1812                <?php endforeach; ?>
1813            </div>
1814            <div class="bw-image-divider"><span><?php esc_html_e( 'or', 'jetpack-mu-wpcom' ); ?></span></div>
1815            <?php else : ?>
1816            <p class="bw-postpicker-empty"><?php esc_html_e( 'No recent drafts', 'jetpack-mu-wpcom' ); ?></p>
1817            <?php endif; ?>
1818
1819            <div class="bw-postpicker-input-row">
1820                <label for="bw-postpicker-url-input" class="bw-visually-hidden"><?php esc_html_e( 'Post URL or ID', 'jetpack-mu-wpcom' ); ?></label>
1821                <input
1822                    id="bw-postpicker-url-input"
1823                    type="text"
1824                    class="bw-image-url-input"
1825                    placeholder="<?php esc_attr_e( 'Paste a post URL or enter a post ID', 'jetpack-mu-wpcom' ); ?>"
1826                    data-wp-bind--value="state.postPickerUrl"
1827                    data-wp-on--input="actions.updatePostPickerUrl"
1828                    data-wp-on--keydown="actions.handlePostPickerInputKeyDown"
1829                />
1830                <button class="bw-btn bw-btn-publish bw-postpicker-go" data-wp-on--click="actions.submitPostPickerUrl"><?php esc_html_e( 'Go', 'jetpack-mu-wpcom' ); ?></button>
1831            </div>
1832        </div>
1833    </div>
1834
1835</div>
1836    <?php
1837}