PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / compat / divi.php

divi.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.6, at includes/compat/divi.php

515 lines 19.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Divi compatibility — script dependency repair.
4 *
5 * Divi (both the theme and the standalone Divi Builder plugin)
6 * registers its block-editor bundle `et-builder-gutenberg` with
7 * only `[ 'jquery', 'wp-hooks' ]` as dependencies. The bundle
8 * calls `wp.data.select( 'core/editor' ).isCleanNewPost` at
9 * module-load time (top-level statement, not inside a function),
10 * so it needs the `core/editor` data store to be registered before
11 * it executes. Without `wp-editor` (which pulls in `wp-data` and
12 * registers `core/editor`) in the dep array, WordPress doesn't
13 * guarantee that ordering — and when the bundle wins the race the
14 * `select( ... )` call returns `undefined` and the bundle throws:
15 *
16 * Uncaught TypeError: Cannot read properties of undefined
17 * (reading 'isCleanNewPost')
18 *
19 * The rest of Divi's React integration never mounts: no `Use Divi
20 * Builder` block on new posts, no `PluginSidebar`, no toggle. To
21 * the user it looks like Divi simply doesn't work inside a desktop
22 * window.
23 *
24 * We inject the missing deps onto the existing registration so the
25 * script loader prints `wp-editor`'s graph first and the bundle
26 * runs against a populated `wp.data`. The shim is idempotent: if
27 * Divi later ships the fix upstream (or renames the handle), this
28 * becomes a no-op.
29 *
30 * Reported to Elegant Themes. Remove this file when Divi ships
31 * the fix upstream.
32 *
33 * @since 0.8.6
34 * @package WP_Desktop_Mode\Compat
35 */
36
37 defined( 'ABSPATH' ) || exit;
38
39 /**
40 * Inject `wp-data` and `wp-editor` as dependencies on Divi's
41 * `et-builder-gutenberg` script registration, and (inside a
42 * chromeless iframe) override Divi's `window.et_gb` assignment so
43 * the bundle's webpack externals resolve to the iframe's own
44 * `wp.data`.
45 *
46 * Two problems on the same script registration:
47 *
48 * 1. **Missing deps.** Divi declares only `[ jquery, wp-hooks ]`
49 * but the bundle reads from `wp.data` at module-load time. We
50 * add `wp-data` + `wp-editor` so the loader prints them first.
51 *
52 * 2. **`window.et_gb` resolves to the wrong frame.** Divi's
53 * bundle is webpack-built with `@wordpress/data` externalised
54 * to `window.et_gb.wp.data` — not `window.wp.data`. The inline
55 * script Divi adds (`before` the bundle) sets `window.et_gb`
56 * via this expression:
57 *
58 * window.et_gb = (window.top && window.top.Cypress && …)
59 * || window.top // ← falls through to here
60 * || window;
61 *
62 * In classic admin `window.top === window`, so `et_gb =
63 * window` and `et_gb.wp.data` is the page's own `wp.data`.
64 * Inside our chromeless iframe `window.top` is the desktop
65 * shell — a different document with no `wp.data` — so
66 * `et_gb.wp.data` is undefined and the bundle throws on first
67 * access (`Cannot read properties of undefined (reading
68 * 'isCleanNewPost')`). The rest of Divi's React integration
69 * never mounts: no `Use Divi Builder` block on new posts, no
70 * `PluginSidebar`, no toggle.
71 *
72 * Multiple `wp_add_inline_script( …, 'before' )` calls
73 * concatenate in registration order, so appending our own
74 * `window.et_gb = window;` after Divi's lets our assignment
75 * win. Scoped to chromeless requests because Divi's original
76 * intent (use `window.top` when the parent is a Cypress
77 * harness) is sensible in other iframe contexts.
78 *
79 * Hooked at `enqueue_block_editor_assets` priority 999 so it runs
80 * after Divi's own enqueue (priority 4) but before the script
81 * loader prints `<script>` tags.
82 *
83 * Reported to Elegant Themes. Remove this file when Divi ships
84 * the fix upstream.
85 *
86 * @since 0.8.6
87 *
88 * @return void
89 */
90 function desktop_mode_compat_divi_fix_gutenberg_deps() {
91 global $wp_scripts;
92
93 if ( ! ( $wp_scripts instanceof WP_Scripts ) ) {
94 return;
95 }
96
97 if ( ! isset( $wp_scripts->registered['et-builder-gutenberg'] ) ) {
98 return;
99 }
100
101 $registration = $wp_scripts->registered['et-builder-gutenberg'];
102 $existing = (array) $registration->deps;
103
104 foreach ( array( 'wp-data', 'wp-editor' ) as $dep ) {
105 if ( ! in_array( $dep, $existing, true ) ) {
106 $registration->deps[] = $dep;
107 }
108 }
109
110 if ( desktop_mode_is_chromeless_request() ) {
111 wp_add_inline_script(
112 'et-builder-gutenberg',
113 'window.et_gb = window;',
114 'before'
115 );
116 }
117 }
118 add_action( 'enqueue_block_editor_assets', 'desktop_mode_compat_divi_fix_gutenberg_deps', 999 );
119
120 /**
121 * Signal Divi's Visual Builder frame-helpers that the iframe context
122 * is "top-level-equivalent" so its `top_window` export resolves to
123 * the iframe's own `window` instead of the desktop shell.
124 *
125 * The VB front-end bundle includes a helper module
126 * (`frontend-builder/build/frame-helpers.js`) whose `top_window`
127 * resolver does roughly this at load time:
128 *
129 * try { u = !!window.top.document && window.top; }
130 * catch ( _ ) { u = false; }
131 * if ( u && u.__Cypress__ ) {
132 * top_window = ( window.parent === u ) ? window : window.parent;
133 * is_iframe = ( window.parent !== u );
134 * } else if ( u ) {
135 * top_window = u; // ← falls through to here
136 * is_iframe = ( u !== window.self );
137 * }
138 *
139 * Inside a chromeless iframe `window.top` is the desktop shell, so
140 * the `else if ( u )` branch fires: `top_window = window.top` (the
141 * shell), `is_iframe = true`. The rest of Divi's VB then routes
142 * REST nonces, builder state, and DOM ops through a window that
143 * has none of those things — VB sits permanently on its
144 * "et-fb-page-preloading" loader because the state it's waiting
145 * for will never arrive.
146 *
147 * Setting `__Cypress__` on `window.top` (our shell) makes Divi take
148 * the Cypress branch instead. Since we're a single-level iframe
149 * (`window.parent === window.top`), that branch resolves
150 * `top_window = window` (the iframe itself), `is_iframe = false`
151 * — exactly the classic-admin behavior. The flag costs nothing
152 * outside Divi (no other code in the WP stack reads `__Cypress__`)
153 * and is idempotent (we OR with the existing value).
154 *
155 * Scope: only when the current user has desktop mode enabled AND
156 * the rendered document is loaded inside an iframe (`window.top !==
157 * window`). The inline script is a few-byte no-op everywhere else.
158 * Front-end only — admin pages use `et_gb` (see above) and route
159 * through a different compat path.
160 *
161 * Reported to Elegant Themes. Remove this hook when Divi makes
162 * `top_window` iframe-aware upstream.
163 *
164 * @since 0.8.6
165 *
166 * @return void
167 */
168 function desktop_mode_compat_divi_vb_iframe_signal() {
169 if ( is_admin() ) {
170 return;
171 }
172 if ( ! desktop_mode_is_enabled() ) {
173 return;
174 }
175 // Bail when Divi isn't active — the inline script below is
176 // shaped entirely around Divi's frame-helpers and VB preloader.
177 // Other handlers in this file already gate on
178 // `desktop_mode_compat_divi_is_active()`; this one was missed.
179 if ( ! desktop_mode_compat_divi_is_active() ) {
180 return;
181 }
182 // `app_window=1` flags the inner VB iframe Divi spawns inside the
183 // `/?p=N&et_fb=1` page. The outer ("VB-top") frame is what hosts
184 // the visible preloader the user sees; the inner is where Divi
185 // mounts its React app. We only need the preloader bridge on the
186 // VB-top — the inner frame's `__Cypress__` signal is enough.
187 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag set by Divi itself when constructing the inner iframe.
188 $is_app_frame = isset( $_GET['app_window'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['app_window'] ) );
189 ?>
190 <script id="desktop-mode-compat-divi-vb">
191 ( function () {
192 if ( window.top === window ) { return; }
193 <?php if ( $is_app_frame ) : ?>
194 // Inside Divi's own inner builder iframe. We only want to
195 // taint window.top with the Cypress flag when DM is wrapping
196 // the whole stack (3 frames deep: shell, chromeless,
197 // builder). At top-level VB flow (2 frames deep: builder-top,
198 // builder) window.parent === window.top AND window.top is
199 // the actual builder-top frame. Tainting it there would
200 // mistrain Divi's frame-helpers: it would hit the Cypress
201 // branch, see parent equals top, and resolve top_window =
202 // window (the inner self) instead of the parent. Divi then
203 // can't communicate inner-to-top and the preloader sits up
204 // forever. Bail before doing anything in this case.
205 if ( window.parent === window.top ) { return; }
206 <?php endif; ?>
207 try { window.top.__Cypress__ = window.top.__Cypress__ || true; } catch ( e ) {}
208 <?php if ( ! $is_app_frame ) : ?>
209 /*
210 * VB-top preloader bridge.
211 *
212 * Divi's `visual-builder/build/root.js` clears the preloader by
213 * removing the `et-fb-page-preloading` class from `#et-fb-app`
214 * and `#et-fb-app-body-root` in two places: its own document AND
215 * `window.top.document`. The intent is "and also clear it on the
216 * outer VB-top frame I'm rendered into." In classic admin
217 * `window.top` IS the VB-top, so that works.
218 *
219 * In a Desktop Mode chromeless iframe, the nesting is one deeper
220 * — the inner React app's `window.top` is the desktop shell,
221 * which has no Divi elements. Root.js cleans its own doc and
222 * no-ops on the shell, leaving THIS document's preloader stuck
223 * forever.
224 *
225 * Mirror the removal here: once the inner app-frame's
226 * `#et-fb-app` loses the preloading class (the canonical signal
227 * that Divi finished mounting), strip it from this document's
228 * `#et-fb-app` / `#et-fb-app-body-root`. Same-origin gives us
229 * direct access to the child iframe's document, so a
230 * MutationObserver on the child suffices. A 30s watchdog
231 * timeout strips the preloader even if the observer never fires
232 * (e.g. Divi's React errors out silently inside the inner
233 * frame) — better a broken builder visible than an invisible
234 * spinner forever.
235 */
236 function clearLocalPreloader() {
237 [ 'et-fb-app', 'et-fb-app-body-root' ].forEach( function ( id ) {
238 var el = document.getElementById( id );
239 if ( el ) { el.classList.remove( 'et-fb-page-preloading' ); }
240 } );
241 }
242 function bridgeAppFrame( appFrame ) {
243 var idoc = null;
244 try { idoc = appFrame.contentDocument; } catch ( e ) {}
245 if ( ! idoc ) {
246 appFrame.addEventListener( 'load', function () { bridgeAppFrame( appFrame ); }, { once: true } );
247 return;
248 }
249 function check() {
250 var inner = idoc.getElementById( 'et-fb-app' ) || idoc.getElementById( 'et-fb-app-body-root' );
251 if ( inner && ! inner.classList.contains( 'et-fb-page-preloading' ) ) {
252 clearLocalPreloader();
253 return true;
254 }
255 return false;
256 }
257 if ( check() ) { return; }
258 var mo = new MutationObserver( function () { if ( check() ) { mo.disconnect(); } } );
259 mo.observe( idoc.documentElement, { attributes: true, subtree: true, attributeFilter: [ 'class' ] } );
260 setTimeout( function () { mo.disconnect(); clearLocalPreloader(); }, 30000 );
261 }
262 function hunt() {
263 var f = document.getElementById( 'et-vb-app-frame' );
264 if ( f ) { bridgeAppFrame( f ); return; }
265 var bodyMo = new MutationObserver( function () {
266 var found = document.getElementById( 'et-vb-app-frame' );
267 if ( found ) { bodyMo.disconnect(); bridgeAppFrame( found ); }
268 } );
269 bodyMo.observe( document.documentElement, { childList: true, subtree: true } );
270 }
271 if ( document.readyState === 'loading' ) {
272 document.addEventListener( 'DOMContentLoaded', hunt );
273 } else {
274 hunt();
275 }
276 <?php endif; ?>
277 } )();
278 </script>
279 <?php
280 }
281 add_action( 'wp_head', 'desktop_mode_compat_divi_vb_iframe_signal', 1 );
282
283 /**
284 * Iframe-side: hijack clicks on Divi's "Use Divi Builder" /
285 * "Edit With The Divi Builder" buttons and links, and hand the
286 * navigation off to the parent shell so the user can opt into a
287 * top-level browser tab for the editing session.
288 *
289 * Why we hijack instead of letting Divi navigate:
290 *
291 * Divi's Visual Builder fundamentally doesn't behave well inside
292 * Desktop Mode's nested iframe chain (shell -> chromeless iframe
293 * -> Divi's inner app-frame). Earlier attempts to transparently
294 * eject mid-navigation hit a chain of subtle race conditions —
295 * Divi captures `Location.prototype` references early, makes its
296 * REST save through a path our `fetch`/`XHR` wraps don't reach,
297 * and the page-leave tears down our console before any diagnostic
298 * we add survives the navigation. The honest fix is to ask the
299 * user, explicitly, whether they want to leave Desktop Mode for
300 * this edit session.
301 *
302 * Detection is by visible text content on the clicked element
303 * rather than by selector — Divi changes the button class across
304 * versions but the user-facing label has been stable for years.
305 * We match: "Use Divi Builder", "Use The Divi Builder", "Edit
306 * With The Divi Builder", "Edit With Divi" (case-insensitive,
307 * trimmed). The "Use Default Editor" sibling button is not in the
308 * match set, so users can still keep editing in Gutenberg.
309 *
310 * Scope: chromeless requests only, and only when Divi is active.
311 * The handler also walks every same-origin nested iframe so the
312 * Gutenberg editor canvas (when Gutenberg keeps it for non-Divi
313 * blocks) is covered.
314 *
315 * @since 0.8.6
316 *
317 * @return void
318 */
319 function desktop_mode_compat_divi_eject_iframe_patch() {
320 if ( ! desktop_mode_is_chromeless_request() ) {
321 return;
322 }
323 if ( ! desktop_mode_compat_divi_is_active() ) {
324 return;
325 }
326 ?>
327 <script id="desktop-mode-compat-divi-vb-handoff">
328 ( function () {
329 var BTN_TEXTS = [
330 'use divi builder',
331 'use the divi builder',
332 'edit with the divi builder',
333 'edit with divi',
334 ];
335 function matchesDiviVbButton( el ) {
336 if ( ! el || ! el.tagName ) { return false; }
337 var tag = el.tagName;
338 if ( tag !== 'BUTTON' && tag !== 'A' && tag !== 'INPUT' && tag !== 'SPAN' ) { return false; }
339 var raw = ( el.textContent || el.value || el.getAttribute( 'aria-label' ) || '' );
340 var text = String( raw ).replace( /\s+/g, ' ' ).trim().toLowerCase();
341 return BTN_TEXTS.indexOf( text ) !== -1;
342 }
343 function postHandoff( currentUrl ) {
344 try {
345 window.top.postMessage(
346 { type: 'desktop-mode-divi-vb-handoff', url: String( currentUrl ) },
347 window.location.origin
348 );
349 } catch ( e ) {}
350 }
351 function onClick( e ) {
352 if ( e.defaultPrevented ) { return; }
353 if ( e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) { return; }
354 var el = e.target;
355 var match = null;
356 while ( el && el.nodeType === 1 ) {
357 if ( matchesDiviVbButton( el ) ) { match = el; break; }
358 el = el.parentNode;
359 }
360 if ( ! match ) { return; }
361 e.preventDefault();
362 e.stopPropagation();
363 if ( typeof e.stopImmediatePropagation === 'function' ) {
364 e.stopImmediatePropagation();
365 }
366 postHandoff( window.location.href );
367 }
368 function attachClickListener( doc ) {
369 try {
370 if ( doc.__desktopModeDiviHandoffAttached ) { return; }
371 doc.__desktopModeDiviHandoffAttached = true;
372 doc.addEventListener( 'click', onClick, true );
373 } catch ( e ) {}
374 }
375 function walkAndAttach( root ) {
376 attachClickListener( root );
377 var frames;
378 try { frames = root.querySelectorAll( 'iframe' ); }
379 catch ( e ) { return; }
380 frames.forEach( function ( iframe ) {
381 try {
382 if ( iframe.contentDocument ) { walkAndAttach( iframe.contentDocument ); }
383 } catch ( e ) {}
384 if ( iframe.__desktopModeDiviHandoffHooked ) { return; }
385 iframe.__desktopModeDiviHandoffHooked = true;
386 iframe.addEventListener( 'load', function () {
387 try { if ( iframe.contentDocument ) { walkAndAttach( iframe.contentDocument ); } } catch ( e ) {}
388 } );
389 } );
390 }
391 function bootstrap() {
392 walkAndAttach( document );
393 new MutationObserver( function () { walkAndAttach( document ); } )
394 .observe( document.documentElement, { subtree: true, childList: true } );
395 }
396 if ( document.readyState === 'loading' ) {
397 document.addEventListener( 'DOMContentLoaded', bootstrap );
398 } else {
399 bootstrap();
400 }
401 } )();
402 </script>
403 <?php
404 }
405 add_action( 'admin_head', 'desktop_mode_compat_divi_eject_iframe_patch', 0 );
406
407 /**
408 * Parent-shell side: receive the handoff message, ask the user
409 * to confirm via `wp.desktop.confirm()`, and on accept navigate
410 * `window.top.location.href` to the iframe's current URL — which
411 * is the post-edit page. The user lands at top level on the same
412 * post they were editing, clicks "Use Divi Builder" again with a
413 * single browser tab, and Divi runs in its native single-frame
414 * environment.
415 *
416 * Two clicks total to enter VB, but each is deliberate. No
417 * detection magic, no race conditions, no transparent eject.
418 *
419 * Same-origin guards: the message event must originate from our
420 * own origin AND the URL we navigate to must parse back to the
421 * same origin. Foreign frames can't trigger the handoff.
422 *
423 * @since 0.8.6
424 *
425 * @return void
426 */
427 function desktop_mode_compat_divi_eject_parent_listener() {
428 if ( ! desktop_mode_is_enabled() ) {
429 return;
430 }
431 if ( desktop_mode_is_chromeless_request() || desktop_mode_is_classic_request() ) {
432 return;
433 }
434 if ( ! desktop_mode_compat_divi_is_active() ) {
435 return;
436 }
437 ?>
438 <script id="desktop-mode-compat-divi-vb-handoff-parent">
439 ( function () {
440 // Reshape the iframe's URL into a top-level classic-admin URL.
441 // The iframe carries `desktop_mode_chromeless=1`, which would
442 // keep the chromeless render alive even at top level — leaving
443 // the user on what looks like the same headless Gutenberg they
444 // already had inside the window. We want a normal wp-admin page
445 // instead, so strip that flag and add `desktop_mode_classic=1`
446 // so our own `desktop_mode_redirect_plain_admin_to_portal()` in
447 // `includes/portal.php` skips its portal-bounce for this load.
448 function handoffUrl( raw ) {
449 try {
450 var parsed = new URL( String( raw || '' ), window.location.href );
451 if ( parsed.origin !== window.location.origin ) { return null; }
452 parsed.searchParams.delete( 'desktop_mode_chromeless' );
453 parsed.searchParams.set( 'desktop_mode_classic', '1' );
454 return parsed.toString();
455 } catch ( e ) { return null; }
456 }
457 window.addEventListener( 'message', function ( ev ) {
458 if ( ev.origin !== window.location.origin ) { return; }
459 if ( ! ev.data || ev.data.type !== 'desktop-mode-divi-vb-handoff' ) { return; }
460 var url = handoffUrl( ev.data.url );
461 if ( ! url ) { return; }
462 var promptUser;
463 if ( window.wp && window.wp.desktop && typeof window.wp.desktop.confirm === 'function' ) {
464 promptUser = window.wp.desktop.confirm( {
465 title: 'Divi needs its own browser tab',
466 message: 'Divi\u2019s Visual Builder cannot run inside a Desktop Mode window \u2014 it needs the full browser tab to render and save correctly. There is no workaround on our side; Divi simply doesn\u2019t support being nested.',
467 confirmLabel: 'Open Divi in this tab',
468 hideCancel: true,
469 dismissable: true,
470 } );
471 } else {
472 // Defense-in-depth no-op. `wp.desktop.confirm` is
473 // reliably present on every shell page where this
474 // listener emits, so this branch is unreachable in
475 // practice. A `window.confirm` here would violate the
476 // codebase-wide "no native dialogs" rule (see CLAUDE.md);
477 // resolving false is the safer empty fallback.
478 promptUser = Promise.resolve( false );
479 }
480 Promise.resolve( promptUser ).then( function ( ok ) {
481 if ( ok ) { window.top.location.href = url; }
482 } );
483 } );
484 } )();
485 </script>
486 <?php
487 }
488 add_action( 'admin_footer', 'desktop_mode_compat_divi_eject_parent_listener', 1 );
489
490
491 /**
492 * Detect whether Divi (theme or standalone Divi Builder plugin)
493 * is active. Used to gate both the iframe-side patcher and the
494 * parent-side listener — both no-ops on non-Divi sites.
495 *
496 * @since 0.8.6
497 *
498 * @return bool True when the Divi theme is active OR the Divi
499 * Builder plugin is active.
500 */
501 function desktop_mode_compat_divi_is_active() {
502 $theme = wp_get_theme();
503 if ( $theme instanceof WP_Theme ) {
504 $name = (string) $theme->get( 'Name' );
505 $template = (string) $theme->get_template();
506 if ( 'Divi' === $name || 'Divi' === $template ) {
507 return true;
508 }
509 }
510 if ( function_exists( 'is_plugin_active' ) && is_plugin_active( 'divi-builder/divi-builder.php' ) ) {
511 return true;
512 }
513 return false;
514 }
515