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