Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 132 additions & 18 deletions includes/class-newspack-popups-inserter.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,28 @@ final class Newspack_Popups_Inserter {
*/
private static $header_template_part_has_rendered = false;

/**
* Overlay prompts queued for rendering at wp_footer, keyed by popup ID. A
* popup that would otherwise be emitted from multiple injection points
* (singular content, archive header, block-theme header) only renders once.
* Queue order is the insertion order – callers that care about which
* overlay wins the single visible slot (i.e. segmentation specificity) must
* queue most-specific first.
*
* @var array<int|string, array<string, mixed>>
*/
private static $queued_overlays = [];

/**
* Popup IDs whose scroll-trigger page-position marker has already been
* emitted inline this request. Parallel to {@see $queued_overlays} so the
* marker (which lives inside `.entry-content`, separate from the queued
* lightbox) also dedupes across multi-emission paths.
*
* @var array<int|string, bool>
*/
private static $emitted_markers = [];

/**
* Constructor.
*/
Expand All @@ -65,6 +87,15 @@ public function __construct() {
add_filter( 'render_block', [ $this, 'insert_inline_prompt_in_block_theme_archives' ], 10, 3 );
add_action( 'wp_before_admin_bar_render', [ $this, 'add_preview_toggle' ] );

// Flush queued overlay prompts at wp_footer. Echoing from a wp_footer
// callback (with no surrounding container in the callback itself) lands
// the markup as a direct child of <body>, which is what lets it escape
// any ancestor stacking context that would otherwise trap its z-index.
// Priority 9 runs *before* wp_print_footer_scripts (priority 20), so any
// asset that an overlay's content block / shortcode enqueues at render
// time still makes it into the page's footer scripts.
add_action( 'wp_footer', [ __CLASS__, 'print_queued_overlays' ], 9 );

Comment thread
adekbadek marked this conversation as resolved.
// Always enqueue scripts, since this plugin's scripts are handling pageview sending via GTAG.
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
add_action( 'apple_news_do_fetch_exporter', [ __CLASS__, 'apple_news_do_fetch_exporter' ] );
Expand Down Expand Up @@ -456,13 +487,20 @@ function ( $block_groups, $block ) use ( &$block_index, $parsed_blocks, $max_ind
}
}

// 4. Insert overlay prompts at the top of content.
// To leave the existing behavior (prepending each overlay) in place,
// we reverse our sorted overlays to ensure the most specific appear
// first in the DOM, and get priority for the single available slot.
$overlay_popups = array_reverse( self::sort_overlays_by_specificity( $overlay_popups ) );
foreach ( $overlay_popups as $overlay_popup ) {
$output = '<!-- wp:html -->' . Newspack_Popups_Model::generate_popup( $overlay_popup ) . '<!-- /wp:html -->' . $output;
// 4. Queue overlay prompts for footer rendering, most-specific first so
// it wins the single visible slot (the client-side reveal walks prompts
// in DOM order and picks the first that passes segment + frequency
// gates). Scroll-triggered overlays carry a page-position marker which
// must remain inline in `.entry-content` – its percentage `top` resolves
// against the article column and drives the IntersectionObserver that
// reveals the lightbox. Wrap the marker in a wp:html block so any
// downstream block-parser pass over the_content output leaves it alone.
foreach ( self::sort_overlays_by_specificity( $overlay_popups ) as $overlay_popup ) {
self::queue_overlay( $overlay_popup );
$marker = self::emit_position_marker_inline( $overlay_popup );
if ( '' !== $marker ) {
$output = '<!-- wp:html -->' . $marker . '<!-- /wp:html -->' . $output;
}
}
return $output;
}
Expand Down Expand Up @@ -551,7 +589,11 @@ function ( $popup ) use ( $shortcoded_popups_ids ) {
}

/**
* Insert overlay prompts into archive pages if needed. Applies to Newspack Theme only.
* Queue archive-page overlay prompts (Newspack classic theme `after_header`
* hook). The actual lightbox markup is emitted later from
* {@see print_queued_overlays()}; the scroll-trigger page-position marker
* is emitted inline here so its `top` percentage resolves against the
* archive's content container rather than against `<body>`.
*/
public static function insert_popups_after_header() {
/* Posts and pages are covered by the_content hook */
Expand All @@ -564,10 +606,76 @@ function ( $popup ) {
return Newspack_Popups_Model::should_be_inserted_in_page_content( $popup ) && Newspack_Popups_Model::is_overlay( $popup );
}
);
$popups = self::sort_overlays_by_specificity( array_values( $popups ) );
foreach ( $popups as $popup ) {
echo Newspack_Popups_Model::generate_popup( $popup ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
foreach ( self::sort_overlays_by_specificity( array_values( $popups ) ) as $popup ) {
self::queue_overlay( $popup );
echo self::emit_position_marker_inline( $popup ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
Comment thread
adekbadek marked this conversation as resolved.
}
}

/**
* Queue an overlay popup for rendering at wp_footer.
*
* Overlays are deduped by popup ID, so the same overlay reached from
* multiple injection points (singular content + above-header, archive
* header, etc.) only renders once. Insertion order is preserved – callers
* that care about specificity (the most-specific overlay winning the
* single visible slot) must queue most-specific first.
*
* @param array<string, mixed> $popup Popup data as returned by Newspack_Popups_Model.
*/
private static function queue_overlay( array $popup ): void {
if ( empty( $popup['id'] ) ) {
return;
}
$id = $popup['id'];
if ( ! isset( self::$queued_overlays[ $id ] ) ) {
self::$queued_overlays[ $id ] = $popup;
}
}

/**
* Return the inline page-position marker markup for a popup, deduped by ID
* across multiple emission paths so a single popup never produces duplicate
* marker DOM nodes (which would break `document.getElementById` lookups in
* the front-end reveal JS). Returns raw marker HTML; callers that emit into
* a context where the result may be re-parsed by the block parser
* (`the_content` output) are responsible for wrapping it in a `wp:html`
* block themselves. Returns the empty string for non-scroll-triggered
* popups and for popups whose marker has already been emitted this request.
*
* @param array<string, mixed> $popup Popup data as returned by Newspack_Popups_Model.
* @return string Raw marker HTML, or '' when no marker should be emitted.
*/
private static function emit_position_marker_inline( array $popup ): string {
if ( empty( $popup['id'] ) || isset( self::$emitted_markers[ $popup['id'] ] ) ) {
return '';
}
$marker = Newspack_Popups_Model::generate_position_marker( $popup );
if ( '' === $marker ) {
return '';
}
self::$emitted_markers[ $popup['id'] ] = true;
return $marker;
}

/**
* Flush queued overlay popups via wp_footer. With no surrounding container
* in the callback, the markup lands as a direct child of `<body>` – escaping
* any ancestor stacking context (a transformed/scaled wrapper, a sticky ad
* container, an element with `isolation: isolate`, etc.) that would
* otherwise trap the lightbox's z-index below sibling content. Inline
* popups remain inline; only the overlay-typed placements are portaled.
* The scroll-trigger page-position marker stays inline at the content
* position (see {@see emit_position_marker_inline()}).
*/
public static function print_queued_overlays(): void {
if ( empty( self::$queued_overlays ) ) {
return;
}
foreach ( self::$queued_overlays as $popup ) {
echo Newspack_Popups_Model::generate_popup( $popup, false ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
self::$queued_overlays = [];
}

/**
Expand All @@ -582,13 +690,22 @@ private static function get_before_header_markup() {
return '';
}

// Sort only the overlay subset by specificity above-header inline prompts are
// Sort only the overlay subset by specificity above-header inline prompts are
// not subject to the single visible overlay slot constraint and are left in their
// original order.
// original order. Overlay lightboxes are queued for footer rendering (escaping
// nested stacking contexts); the scroll-trigger marker is prepended inline so
// its `top` percentage still resolves against the header/post container. Inline
// (non-overlay) above-header prompts continue to be emitted inline here.
$overlay_popups = self::sort_overlays_by_specificity(
array_values( array_filter( $before_header_popups, [ 'Newspack_Popups_Model', 'is_overlay' ] ) )
);
$inline_popups = array_values(
$markers = '';
foreach ( $overlay_popups as $popup ) {
self::queue_overlay( $popup );
$markers .= self::emit_position_marker_inline( $popup );
}

$inline_popups = array_values(
array_filter(
$before_header_popups,
function( $popup ) {
Expand All @@ -597,10 +714,7 @@ function( $popup ) {
)
);

$markup = '';
foreach ( $overlay_popups as $popup ) {
$markup .= Newspack_Popups_Model::generate_popup( $popup );
}
$markup = $markers;
foreach ( $inline_popups as $popup ) {
$markup .= Newspack_Popups_Model::generate_popup( $popup );
}
Expand Down
53 changes: 50 additions & 3 deletions includes/class-newspack-popups-model.php
Original file line number Diff line number Diff line change
Expand Up @@ -1085,13 +1085,60 @@ public static function get_current_popup() {
return self::$current_popup;
}

/**
* Generate just the scroll-trigger page-position marker for an overlay popup.
*
* The marker is a `position: absolute; top: X%` element whose position is
* computed against its nearest `position: relative` ancestor (typically the
* post's `.entry-content`). An IntersectionObserver on this marker is what
* reveals the scroll-triggered overlay. The lightbox is portaled to
* wp_footer to escape ancestor stacking-context traps, but the marker
* stays inline at the article container so its percentage offset still
* encodes "scroll progress through the article".
*
* Returns the empty string for popups that aren't scroll-triggered overlays.
*
* @param array<string, mixed> $popup A fully-hydrated popup object as returned
* by {@see create_popup_object()}.
* @return string Marker HTML, or '' when no marker is needed.
*/
public static function generate_position_marker( array $popup ): string {
if ( ! self::is_overlay( $popup ) ) {
return '';
}
$trigger_type = $popup['options']['trigger_type'] ?? '';
if ( 'scroll' !== $trigger_type ) {
return '';
}
$element_id = self::canonize_popup_id( $popup['id'] );
$progress = absint( $popup['options']['trigger_scroll_progress'] ?? 0 );
return sprintf(
'<div id="page-position-marker_%1$s" class="page-position-marker" style="position: absolute; top: %2$d%%"></div>',
esc_attr( $element_id ),
$progress
);
}

/**
* Generate markup and styles for an overlay popup.
*
* @param string $popup The popup object.
* @param array<string, mixed> $popup A fully-hydrated popup object.
* @param bool $include_position_marker When true (default), the
* returned markup includes the
* scroll-trigger page-position
* marker. Pass false when the
* marker is emitted separately
* at the content position via
* {@see generate_position_marker()},
* so the marker's percentage `top`
* resolves against its
* `position: relative` ancestor
* (typically `.entry-content`)
* rather than against the portaled
* lightbox's footer position.
* @return string The generated markup.
*/
public static function generate_popup( $popup ) {
public static function generate_popup( $popup, bool $include_position_marker = true ) {
$previewed_popup_id = Newspack_Popups::previewed_popup_id();
$is_manual_or_custom_placement = self::is_manual_only( $popup ) || Newspack_Popups_Custom_Placements::is_custom_placement_or_manual( $popup );

Expand Down Expand Up @@ -1204,7 +1251,7 @@ class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
<?php endif; ?>
<?php endif; ?>
</div>
<?php if ( $is_scroll_triggered ) : ?>
<?php if ( $is_scroll_triggered && $include_position_marker ) : ?>
<div id="page-position-marker_<?php echo esc_attr( $element_id ); ?>" class="page-position-marker" style="position: absolute; top: <?php echo esc_attr( $popup['options']['trigger_scroll_progress'] ); ?>%"></div>
<?php endif; ?>
<?php
Expand Down
48 changes: 19 additions & 29 deletions src/view/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -395,43 +395,33 @@ $width__tablet: 782px;
}
}

// Alignment
.entry-content {
/* stylelint-disable-next-line no-duplicate-selectors */
.newspack-lightbox {
.newspack-popup__content-wrapper {
.alignfull,
.alignwide {
margin-left: 0;
margin-right: 0;
max-width: 100%;
padding-left: 0;
padding-right: 0;

@media only screen and ( min-width: $width__mobile ) {
&.wp-block-columns {
margin-left: -16px;
margin-right: -16px;
max-width: calc(100% + 32px);
width: calc(100% + 32px);
}
}

&.wp-block-columns,
&.wp-block-columns .wp-block-column {
padding-left: 0;
padding-right: 0;
}
}
// Alignment for wide/full blocks inside overlay prompt content. The lightbox
// renders as a direct child of <body> (see Newspack_Popups_Inserter::print_queued_overlays),
// so these rules are no longer scoped to .entry-content.
.newspack-lightbox {
.newspack-popup__content-wrapper {
.alignfull,
.alignwide {
margin-left: 0;
margin-right: 0;
max-width: 100%;
padding-left: 0;
padding-right: 0;

@media only screen and ( min-width: $width__mobile ) {
.alignfull.wp-block-columns {
&.wp-block-columns {
margin-left: -16px;
margin-right: -16px;
max-width: calc(100% + 32px);
width: calc(100% + 32px);
}
}

&.wp-block-columns,
&.wp-block-columns .wp-block-column {
padding-left: 0;
padding-right: 0;
}
}
}
}
Expand Down
19 changes: 13 additions & 6 deletions tests/test-block-theme-header-insertion.php
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,20 @@ public function test_overlay_specificity_order_is_preserved() {

$block_content = '<div class="wp-block-template-part">header content</div>';
$block = $this->get_header_template_part_block();
$result = Newspack_Popups_Inserter::insert_before_header_in_template_part( $block_content, $block );
Newspack_Popups_Inserter::insert_before_header_in_template_part( $block_content, $block );

$segment_pos = strpos( $result, 'Segment overlay' );
$generic_pos = strpos( $result, 'Generic overlay' );
// Overlays are portaled to wp_footer rather than inlined into the
// header template-part output; assert ordering against the queued
// flush, which is what gets emitted before </body>.
ob_start();
Newspack_Popups_Inserter::print_queued_overlays();
$footer_output = ob_get_clean();

$this->assertNotFalse( $segment_pos, 'Segment-specific overlay should be rendered.' );
$this->assertNotFalse( $generic_pos, 'Generic overlay should be rendered.' );
$this->assertLessThan( $generic_pos, $segment_pos, 'Segment-specific overlay should render before generic overlay.' );
$segment_pos = strpos( $footer_output, 'Segment overlay' );
$generic_pos = strpos( $footer_output, 'Generic overlay' );

$this->assertNotFalse( $segment_pos, 'Segment-specific overlay should be queued for footer rendering.' );
$this->assertNotFalse( $generic_pos, 'Generic overlay should be queued for footer rendering.' );
$this->assertLessThan( $generic_pos, $segment_pos, 'Segment-specific overlay should be emitted before generic overlay.' );
}
}
21 changes: 16 additions & 5 deletions tests/test-insertion.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,23 @@ public function test_insertion_on_page() {
$overlay_id = self::createPopup( $overlay_content, [ 'placement' => 'center' ] );
$page_with_shortcode = '[newspack-popups id="' . $overlay_id . '"]';
self::renderPost( '', $page_with_shortcode, [], [], 'page' );
$overlay_text_content = self::$dom_xpath->query( '//*[contains(@class,"newspack-popup-container")]' )->item( 0 )->textContent;

self::assertStringContainsString(
$overlay_content,
$overlay_text_content,
'Inserts the overlay prompt on a page.'
// Overlays are portaled to wp_footer, so the inline default popup
// (from set_up) is item(0) and the overlay sits later in the combined
// content+footer markup. Locate the overlay by its content instead of
// by DOM position.
$popup_elements = self::$dom_xpath->query( '//*[contains(@class,"newspack-popup-container")]' );
$found_overlay = false;
foreach ( $popup_elements as $popup_element ) {
if ( false !== strpos( $popup_element->textContent, $overlay_content ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMNode property.
$found_overlay = true;
break;
}
}

self::assertTrue(
$found_overlay,
'Inserts the overlay prompt on a page (portaled to footer, located via content).'
);
}

Expand Down
Loading
Loading