Skip to content
Merged
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
1 change: 1 addition & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@
"php tests/unit/responsive-counterpart-contracts.php",
"php tests/unit/captured-dialog-projector.php",
"php tests/unit/captured-dialog-source-panel-box.php",
"php tests/unit/captured-dialog-open-state.php",
"php tests/unit/captured-dialog-dropdown-panel.php",
"php tests/unit/scoped-mixed-dropdown.php",
"php tests/unit/capture-runtime-ancestor-parity.php",
Expand Down
5 changes: 5 additions & 0 deletions php-transformer/src/Css/CssSelectorMatchCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Automattic\BlocksEngine\PhpTransformer\Css;

use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker;
use DOMDocument;
use DOMElement;
use DOMNode;
Expand Down Expand Up @@ -199,6 +200,10 @@ public function styleRuleCandidates(DOMElement $element, string $collection, arr
}
foreach ( $this->classTokens($element) as $class ) {
$candidates = array_merge($candidates, $index['classes'][$class] ?? array());
$anchoredId = EngineMarker::editorAnchorId($class);
if ( null !== $anchoredId && $anchoredId !== $id ) {
$candidates = array_merge($candidates, $index['ids'][$anchoredId] ?? array());
}
}
$candidates = array_merge($candidates, $index['tags'][strtolower($element->tagName)] ?? array());
foreach ( $this->attributeNames($element) as $name ) {
Expand Down
12 changes: 10 additions & 2 deletions php-transformer/src/Css/CssSelectorMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatchCache;
use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorTokenizer;
use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker;
use DOMElement;
use DOMNode;

Expand Down Expand Up @@ -822,13 +823,20 @@ private static function matchesCompound(DOMElement $element, array $compound, ?C
return false;
}
$actualId = array() !== $compound['ids'] ? (null !== $cache ? ($cache->attribute($element, 'id') ?? '') : $element->getAttribute('id')) : '';
$classes = null;
foreach ( $compound['ids'] as $id ) {
if ( $actualId !== $id ) {
return false;
// An element that stands in for the id's holder carries the id
// as its editor anchor class (a projected captured dialog whose
// source panel id stays on the capture's hidden copy).
$classes ??= $cache?->classTokens($element) ?? (preg_split('/[\x09\x0A\x0C\x0D\x20]+/', trim($element->getAttribute('class'))) ?: array());
if ( ! in_array(EngineMarker::editorAnchorClass($id), $classes, true) ) {
return false;
}
}
}
if ( array() !== $compound['classes'] ) {
$classes = $cache?->classTokens($element) ?? (preg_split('/[\x09\x0A\x0C\x0D\x20]+/', trim($element->getAttribute('class'))) ?: array());
$classes ??= $cache?->classTokens($element) ?? (preg_split('/[\x09\x0A\x0C\x0D\x20]+/', trim($element->getAttribute('class'))) ?: array());
foreach ( $compound['classes'] as $class ) {
if ( ! in_array($class, $classes, true) ) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ final class CapturedDialogConverter implements ElementConverter
/**
* @param Closure(DOMElement, array<int, array<string, mixed>>&): array<int, array<string, mixed>> $convertChildren
* @param (Closure(DOMElement): string)|null $resolvedStyle The element's authored, cascade-resolved declarations.
* @param (Closure(DOMElement): array<string, string>)|null $openStateValues Values a state rule gives the panel over its hiding or offsetting rest rules.
*/
public function __construct(
private readonly HtmlTransformerSession $session,
private readonly Closure $convertChildren,
private readonly ?Closure $resolvedStyle = null
private readonly ?Closure $resolvedStyle = null,
private readonly ?Closure $openStateValues = null
) {
}

Expand Down Expand Up @@ -56,6 +58,29 @@ private function isSupersededByNativeNavigation(DOMElement $dialog): bool
return false;
}

/**
* The dialog's inline style, plus the open-state values a state rule gives
* the panel over rest rules that hide or offset it. The dialog is the
* captured open panel, so those rest rules must not win while it is open.
* Inline and without `!important`: it beats the rest rules, and the block
* stylesheet's `:not([open]){display:none!important}` still closes it.
*/
private function openSourceStyle(DOMElement $element): string
{
$style = trim(SourceDom::attr($element, 'style'));
if ( 'dialog' !== strtolower($element->tagName) || '' !== SourceDom::attr($element, 'data-blocks-engine-presentation') || null === $this->openStateValues ) {
return $style;
}
foreach ( ($this->openStateValues)($element) as $property => $value ) {
if ( 1 === preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:/i', $style) ) {
continue;
}
$style = rtrim($style, "; \t\n") . ('' === $style ? '' : ';') . $property . ':' . $value;
}

return $style;
}

/**
* A modal whose source panel positions itself (`position:fixed` or
* `absolute` from its own author rules) drew its own box: the source panel
Expand Down Expand Up @@ -85,7 +110,7 @@ public function block(DOMElement $element, array &$fallbacks): array

$attrs = array_filter(array(
'tagName' => 'div' === strtolower($element->tagName) ? 'div' : '',
'sourceStyle' => SourceDom::attr($element, 'style'),
'sourceStyle' => $this->openSourceStyle($element),
'ancestorUnverified' => SourceDom::attr($element, CaptureEvidence::DIALOG_ANCESTOR_UNVERIFIED),
'dialogId' => trim(SourceDom::attr($element, 'id')),
'triggerIds' => array_values(array_filter(preg_split('/\s+/', trim(SourceDom::attr($element, 'data-blocks-engine-triggers'))) ?: array())),
Expand Down
43 changes: 42 additions & 1 deletion php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,8 @@ function (DOMElement $element, array &$fallbacks): ?array {
function (DOMElement $element, array &$fallbacks) use ($convertChildren): array {
return $convertChildren($element, $fallbacks, true);
},
fn (DOMElement $element): string => $this->styleResolver->specificityResolvedPresentationStyle($element)
fn (DOMElement $element): string => $this->styleResolver->specificityResolvedPresentationStyle($element),
fn (DOMElement $element): array => $this->styleResolver->capturedDialogOpenStateValues($element)
);
$this->wrapperCoalescer = new WrapperCoalescer(
$this->sourceElementClassifier,
Expand Down Expand Up @@ -4258,11 +4259,51 @@ private function withPropagatedWrapperTextAlignment(array $innerBlocks, DOMEleme
return $innerBlocks;
}

/**
* Whether any trigger of a captured dialog can show at some viewport: no
* element from the trigger up declares `display:none` at its base without
* a conditional value that shows it, and none carries `hidden`.
*/
private function capturedDialogHasReachableTrigger(DOMElement $dialog): bool
{
$document = $dialog->ownerDocument;
foreach ( preg_split('/\s+/', trim(SourceDom::attr($dialog, 'data-blocks-engine-triggers'))) ?: array() as $id ) {
$trigger = '' !== $id && null !== $document ? $document->getElementById($id) : null;
if ( ! $trigger instanceof DOMElement ) {
continue;
}
$reachable = true;
for ( $node = $trigger; $node instanceof DOMElement && 'body' !== strtolower($node->tagName); $node = $node->parentNode ) {
$display = $this->styleResolver->declaredPresentation($node, 'display');
$base = CssValueInspector::comparable($display->base());
$shownSomewhere = array() !== array_filter($display->conditional(), static fn (string $value): bool => 'none' !== CssValueInspector::comparable($value));
if ( $node->hasAttribute('hidden') || ( 'none' === $base && ! $shownSomewhere ) ) {
$reachable = false;
break;
}
}
if ( $reachable ) {
return true;
}
}

return false;
}

private function sourceElementStartsHidden(DOMElement $element): bool
{
if ( SourceDom::documentVariantRoot($element) === $element ) {
return false;
}
// A projected captured dialog is the panel's open state. Its source
// rest rules hide it until script opens it; the dialog block owns that
// closed state, so they do not make the dialog hidden content.
// A dialog none of whose triggers can ever show is still dropped.
if ( 'dialog' === strtolower($element->tagName) && 'true' === SourceDom::attr($element, 'data-blocks-engine-captured-dialog')
&& $this->capturedDialogHasReachableTrigger($element)
) {
return false;
}
$declarations = $this->styleResolver->structuralPresentationDeclarations($element);
$display = CssValueInspector::comparable((string) ($declarations['display'] ?? ''));
$visibility = CssValueInspector::comparable((string) ($declarations['visibility'] ?? ''));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style;

use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker;
use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatchCache;
use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker;
use Automattic\BlocksEngine\PhpTransformer\WordPress\SourceClassIdentity;
use DOMElement;

Expand Down Expand Up @@ -193,7 +193,9 @@ public function selectorCandidates(array $parsed): array
$rightmost = $compounds[array_key_last($compounds)] ?? array();
$candidates = array();
foreach ( $rightmost['ids'] ?? array() as $id ) {
$candidates[] = $this->sourceElementsById[$id] ?? array();
// A projected captured dialog stands in for the id's holder through
// its editor anchor class, so it is a candidate for the id too.
$candidates[] = array_merge($this->sourceElementsById[$id] ?? array(), $this->sourceElementsByClass[EngineMarker::editorAnchorClass((string) $id)] ?? array());
}
foreach ( $rightmost['classes'] ?? array() as $class ) {
$candidates[] = $this->sourceElementsByClass[$class] ?? array();
Expand Down Expand Up @@ -235,7 +237,7 @@ public function selectorCanMatch(array $parsed): bool
return false;
}
foreach ( $compound['ids'] ?? array() as $id ) {
if ( ! isset($this->sourceIds[$id]) ) {
if ( ! isset($this->sourceIds[$id]) && ! isset($this->sourceElementsByClass[EngineMarker::editorAnchorClass((string) $id)]) ) {
return false;
}
}
Expand Down
148 changes: 148 additions & 0 deletions php-transformer/src/HtmlToBlocks/Style/StyleResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4715,6 +4715,154 @@ private function isSupportedCssSelector(string $selector): bool
return (bool) ($this->context->parsedCssSelector($selector)['supported'] ?? false);
}

/**
* The open-state values a captured modal dialog needs over its rest state.
*
* A projected dialog is the panel's captured open state. Its source panel
* is often hidden or slid out of view at rest (`#panel{display:none;
* transform:translateX(-100%)}`) and shown by a rule that only matches once
* script marks an ancestor (`body.nav-open #panel{display:block;
* transform:none}`). When that ancestor state was not captured, the rest
* rule wins on the open dialog and it opens invisible.
*
* A state rule is an unconditioned rule whose rightmost compound matches
* the dialog but whose full selector does not. Several can exist
* (`.dark-theme #panel`, `.no-js #panel`), so the one to trust is chosen:
* - when the capture recorded classes on an opened ancestor
* (`data-blocks-engine-ancestor-state`), only rules naming one of them
* are the open state (none naming one: nothing is carried);
* - otherwise every state rule is a candidate.
* A property is carried only when the rest cascade hides it, or moves the
* panel a full panel or viewport out of view, and every chosen candidate
* that declares it agrees on one value that shows it. Anything else is
* left to the source rules.
*
* @return array<string, string>
*/
public function capturedDialogOpenStateValues(DOMElement $dialog): array
{
$hidden = array(
'display' => static fn (string $value): bool => 'none' === $value,
'visibility' => static fn (string $value): bool => 'hidden' === $value,
'opacity' => static fn (string $value): bool => is_numeric($value) && 0.0 === (float) $value,
'transform' => static fn (string $value): bool => self::isOutOfViewTransform($value),
);
$openedClasses = self::openedAncestorClasses($dialog);
$rest = array();
$state = array();
$order = 0;
// Raw author rules: the static analysis keeps neither `transform` nor
// rules that match no source element, and a state rule matches none
// until script adds its ancestor class.
(new CssStylesheetTransformer())->visitStyleRules(
$this->context->authorStyles()->combinedCss(),
function (string $prelude, string $body, array $conditions) use ($dialog, $hidden, $openedClasses, &$rest, &$state, &$order): void {
if ( array() !== $conditions ) {
return;
}
$declarations = array_intersect_key($this->cssDeclarations($body), $hidden);
if ( array() === $declarations ) {
return;
}
foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) {
$selector = trim($selector);
$parsed = $this->context->parsedCssSelector($selector);
$span = $parsed['rightmost_compound_span'] ?? null;
if ( ! ($parsed['supported'] ?? false) || ! is_array($span) ) {
continue;
}
if ( $this->matchesCssSelector($dialog, $selector) ) {
$rest[] = array('specificity' => $this->mediaTextSelectorSpecificity($selector), 'order' => $order++, 'declarations' => $declarations);
continue;
}
$subject = substr($selector, (int) $span['start'], (int) $span['end'] - (int) $span['start']);
if ( 0 < (int) $span['start'] && $this->matchesCssSelector($dialog, $subject) ) {
$context = substr($selector, 0, (int) $span['start']);
$state[] = array(
'recorded' => array() !== array_filter($openedClasses, static fn (string $class): bool => 1 === preg_match('/\.' . preg_quote($class, '/') . '(?![\w-])/', $context)),
'declarations' => $declarations,
);
}
}
}
);
usort($rest, static fn (array $left, array $right): int => array($left['specificity'], $left['order']) <=> array($right['specificity'], $right['order']));
$resting = array();
foreach ( $rest as $rule ) {
$resting = array_merge($resting, $rule['declarations']);
}
// A recorded open state names the rules to trust; with none recorded,
// every state rule is a candidate and they must agree.
$candidates = array() !== $openedClasses
? array_values(array_filter($state, static fn (array $rule): bool => $rule['recorded']))
: $state;

$open = array();
foreach ( $hidden as $property => $isHidden ) {
$restValue = CssValueInspector::comparable((string) ( $resting[$property] ?? '' ));
if ( '' === $restValue || ! $isHidden($restValue) ) {
continue;
}
$values = array();
foreach ( $candidates as $rule ) {
$value = trim((string) preg_replace('/\s*!important\s*$/i', '', (string) ( $rule['declarations'][$property] ?? '' )));
if ( '' !== $value ) {
$values[CssValueInspector::comparable($value)] = $value;
}
}
if ( 1 === count($values) && ! $isHidden((string) array_key_first($values)) ) {
$open[$property] = (string) reset($values);
}
}

return $open;
}

/**
* Class tokens the capture recorded on an ancestor only while the dialog
* was open (`data-blocks-engine-ancestor-state`).
*
* @return list<string>
*/
private static function openedAncestorClasses(DOMElement $dialog): array
{
$rows = json_decode($dialog->getAttribute('data-blocks-engine-ancestor-state'), true);
$classes = array();
foreach ( is_array($rows) ? $rows : array() as $row ) {
$opened = preg_split('/\s+/', trim((string) ( $row['opened']['class'] ?? '' ))) ?: array();
$closed = preg_split('/\s+/', trim((string) ( $row['closed']['class'] ?? '' ))) ?: array();
foreach ( array_diff($opened, $closed) as $class ) {
if ( 1 === preg_match('/^-?[_a-zA-Z][\w-]*$/', $class) ) {
$classes[] = $class;
}
}
}

return array_values(array_unique($classes));
}

/**
* Whether a transform moves the panel out of view: a translation of a full
* panel size (100%) or a full viewport (100vw, 100vh) or more along an
* axis. A centring `translate(-50%,-50%)` or a small nudge keeps it in view.
*/
private static function isOutOfViewTransform(string $value): bool
{
$value = strtolower(trim($value));
if ( 0 === preg_match_all('/\btranslate(?:x|y|3d)?\(([^)]*)\)/', $value, $calls) ) {
return false;
}
foreach ( $calls[1] as $arguments ) {
foreach ( preg_split('/\s*,\s*|\s+/', trim($arguments)) ?: array() as $argument ) {
if ( 1 === preg_match('/^-?(\d+(?:\.\d+)?)(%|vw|vh)$/', $argument, $length) && 100.0 <= (float) $length[1] ) {
return true;
}
}
}

return false;
}

public function matchesCssSelector(DOMElement $element, string $selector): bool
{
$cache = $this->context->sourceStyles();
Expand Down
Loading
Loading