diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0121771..33270e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,12 @@ on: [push, pull_request] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} DOCS_PATH: ${{ secrets.DOCS_PATH }} DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }} # Snapshot baselines are per-platform and committed; a missing one is a gap, never a pass. ANGLESHARP_SNAPSHOT_STRICT: 1 - ANGLESHARP_VERSION: 1.8.6 - ANGLESHARP_CSS_VERSION: 1.2.2 + ANGLESHARP_VERSION: 1.9.0 + ANGLESHARP_CSS_VERSION: 1.3.1 jobs: can_document: @@ -99,6 +98,9 @@ jobs: package: needs: [test] runs-on: windows-2022 + permissions: + contents: write # create the GitHub release + id-token: write # request the OIDC token for NuGet trusted publishing steps: - uses: actions/checkout@v5 @@ -110,7 +112,18 @@ jobs: 8.0.x 10.0.x + # Exchanges the GitHub OIDC token for a short-lived (1 hour) NuGet API key. + # Requires a trusted publishing policy on nuget.org for this repository and workflow file. + - name: NuGet login + id: nuget_login + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/devel') + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + - name: Build + env: + NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} run: | if ($env:GITHUB_REF -eq "refs/heads/main") { .\build.ps1 -Target Publish -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml index 450d5a9..8009f77 100644 --- a/.github/workflows/update-snapshots.yml +++ b/.github/workflows/update-snapshots.yml @@ -15,8 +15,8 @@ permissions: contents: write env: - ANGLESHARP_VERSION: 1.8.6 - ANGLESHARP_CSS_VERSION: 1.2.2 + ANGLESHARP_VERSION: 1.9.0 + ANGLESHARP_CSS_VERSION: 1.3.1 jobs: # Keep this matrix identical to the `test` matrix in ci.yml, images included - a baseline diff --git a/AGENTS.md b/AGENTS.md index a5c4f38..c01e2d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,13 +55,14 @@ Upstream status: `font-variant-east-asian` was unparsed; fixed with tests in the - **Paint order = layout order.** `OrderChildrenForPainting` decides the order children are *laid out* too (it threads `cursorY`). Do not reorder it to fix paint stacking; relocate emitted commands instead (`MoveRangeToEnd`, as `position: sticky` does). - **An authored `height` is the height** of block and flex containers; in-flow content overflows it (only `min-height`/`max-height` clamp it). Auto heights come from the laid-out content. - **`box-sizing`** is converted in exactly one place, `ResolveAuthoredDimension`; everything downstream works in content-box sizes. Flex item sizes are already converted before `ResolveFlexibleContentDimension` reads them - do not convert twice. Tables honor it too (their width goes through the same method); the HTML default `table { box-sizing: border-box }` has to come from the UA sheet. -- **Style maps.** `CreateStyleMap` builds a per-element `StyleMap` (string map plus a typed raw-length side channel used by `ParseLength`). Always go through `GetOrCreateStyleMap` (cached per render call in `LayoutContext.StyleMapCache`; the cache must never outlive one `BuildDisplayList`/`RenderToPng`/`CaptureLayoutMetrics` call or interactive state goes stale). Code writing directly into a length entry must call `StyleMap.ClearRawLength` for that key. +- **Style maps.** `CreateStyleMap` builds a per-element `StyleMap` (string map plus a typed raw-length side channel used by `ParseLength`). Always go through `GetOrCreateStyleMap` (cached per render call in `LayoutContext.StyleMapCache`; the cache must never outlive one `BuildDisplayList`/`RenderToPng`/`CaptureLayoutMetrics` call or interactive state goes stale - the one exception is `LayoutSnapshot`, see below). Code writing directly into a length entry must call `StyleMap.ClearRawLength` for that key. - **Explicit vs computed values.** `background-image`, `grid-template-*`, gaps and `grid-column`/`grid-row` are read from the cascaded-but-uncomputed declaration (`ResolveExplicitPropertyValue` via the cached `LayoutContext.StyleCollection`), because AngleSharp.Css's `.Compute()` resolves their percentages against the viewport instead of the element's box. `width`/`height`/`min-*`/`max-*` take the explicit value only when it contains a `%`; anything else (`em`, `rem`, `vw`) needs the computed one. - **The page.** `` is laid out as an ordinary block (so ``'s UA `margin: 8px`, padding, border and width apply); `context.Padding` is 0. `PropagateToViewport` moves the root's background (or ``'s, if the root has none) to the canvas and removes it from that element's style map, and drops their `overflow`. ``'s metrics are re-recorded as the viewport, since it is the scrolling element. Structural tests in `HtmlRendererTests` run with a prepended `body { margin: 0 }` sheet (its `ParseAsync`). The vertical-page path still lays out ``'s children directly. - **Blockification.** AngleSharp.Css blockifies the computed `display` of floats, absolutely positioned boxes and the root, so they are never inline-level; handing one to an inline formatting context would lay it out again forever. Flex items are not blockified upstream (it does not know the formatting context), so `LayoutElement` forces them to block layout itself. - **Margin collapsing** through first children is recursive (`TryGetFirstCollapsibleChildTopMargin`); a box whose margin its parent absorbed (`suppressNextBlockTopMargin`) absorbs the whole chain below it too. Floats and out-of-flow boxes are skipped; inline-level content (atomics included) stops the chain. - **Unset ≠ initial.** AngleSharp.Css often reports an unset property as an empty string rather than its initial value; parsers fall back to the inherited value (inherited properties) or the CSS initial value. - Adding a new `RenderPaint` (or other) subtype: check every place that pattern-matches on the existing ones (`PaintBackground` once silently dropped `RenderImagePaint`). +- **Incremental metrics** (`HtmlRenderer.Incremental.cs`). `InteractiveHtmlRendererState.GetLayoutMetrics` keeps a `LayoutSnapshot`: the metrics plus the `LayoutContext` of the last full layout, the `LayoutElement` arguments of every out-of-flow or not rendered box (`LayoutEntry`, recorded only by a capture with `Entries`, never by trial layouts) and what each block gave its children (`ContainerEntry`). Mutations confined to such a box's subtree (inline `style`; a class/id/attribute no selector with `+`/`~`/`:has(` refers to or matches differently; child lists and text unless they flip the match of a `:has(` compound on the changed element or its ancestors, recorded per snapshot in `_relationalMatches`) re-render that subtree only (AngleSharp.Css `window.Render(element, parent)`), drop its entries from the context's caches, and lay it out again from its entry; anything else is a full layout. Outside the subtree the context's caches stay valid because nothing there changed. In-flow blocks get entries too (`IsInFlow`, with the incoming margins and the outgoing `FlowResult`), but only along a chain of ordinary block flows from the root element (`IsInFlowEntry`: the parent's `ContainerEntry.IsFlow`, no floats beside it), passed on through flex/grid items of chained containers unless baseline-aligned (`LayoutCapture.Links`); behind such an item or a `*-content` width, an entry `IsSizedByContent` and must keep its `ComputeOuterIntrinsicWidths`. Re-laying out an in-flow block must reproduce its box, its `FlowResult` (cursor, collapsed margin, text-indent, no floats left) and, when the parent took its top margin, its collapsed top margin; otherwise (or for sticky/negative-z/counter content, sticky ancestors, `::first-line`/`::first-letter` pages) `TryUpdate` escalates to the next enclosing root (at most 3 times, never the body). Outside the subtree the context's caches stay valid because nothing there changed. Two full layouts can differ in the last float bits (21.6 vs 21.599998), so equivalence tests compare in thousandths. A frame's layout becomes the snapshot (`LayOut` with the frame's scroll offset; metrics are kept as laid out plus a page-coordinate view, `Metrics`). Scrolling and pointer moves keep the snapshot: `ToViewport` maps page coordinates into the viewport for the current scroll (boxes inside fixed boxes - `IsViewportAnchored` - stay; sticky boxes, recorded as `StickyRecord`s, stick anew via `ComputeStickyOffset`). Resolving a sticky box shifts everything recorded during its layout (`LayoutCapture.RecordOrder`), not only the box. Other scroll containers move their content in the layout itself (`GetContentScrollOffset`: a translation inside the clip, `MoveScrolledContent` for the recorded metrics), so their scrolling drops the snapshot; `scrollHeight`/`scrollIntoView` add the container's scroll back. For `IDomHarness.UpdateFrame` the snapshot of a frame also records each entry's command range (`LayoutCapture.Ranges`: first command by identity plus count, which survives `PaintOrder` moves) and, per re-laid-out root since the frame (`Change`), its frame range and latest commands; `TryPatch` replays the re-layouts since the frame (`_relayouts`: range before, commands after) on a copy of the frame's display list, skipping those inside a root laid out later. A root shown since the frame is inserted where `PaintOrder` would have put it: every flow (and flex/grid container, which also records a `ContainerEntry` for the padding box its out-of-flow children are laid out against) records a `PaintLayer` (its raised children in paint order plus its last command) for its not rendered children (`RecordPaintLayer`). A re-layout inside scroll containers is moved by their scroll (`MoveScrolledContent`) like in the full layout. The snapshot also keeps its `LayoutPass` for re-layouts (dropping the subtree's entries, and the ancestors' `PercentageHeights`). - **Flow layout.** `LayoutFlowChildren` (used by `LayoutElement` and the page itself) splits children into block-level children and runs of inline-level content. A run of bare text keeps the paragraph path (`LayoutTextNode`/`LayoutWrappedText`/`WrapText`); any run containing an element goes through `LayoutInlineFormattingContext`: the subtree is flattened into `InlinePiece`s (whitespace collapsed across element boundaries, break opportunities per piece), greedily broken into line boxes, and each line is emitted with one baseline (strut + half-leading per piece + atomic margin boxes). Inline boxes paint per line fragment; block-level boxes inside inline content get a line of their own. - **Atomic inlines and shrink-to-fit.** Inline-blocks (and inline-flex/grid/table, form controls, ``/``) are measured by `TrialLayout`, which lays the element out into a scratch list under a temporary `LayoutCapture` (so real metrics capture is untouched) and reads its border box and last text baseline (`RecordTextBaseline`). Auto widths of atomics, floats and absolutely positioned boxes use `ResolveShrinkToFitBorderBoxWidth` (intrinsic widths from `ComputeContentIntrinsicWidths`), then `LayoutNode(..., forcedBorderBoxWidth, isAtomicInline)`. Nested inline-blocks multiply trial layouts, so keep the measurement paths cheap. A percentage `width` counts as `auto` in an intrinsic contribution (`ComputeOuterIntrinsicWidths`), since it would refer to the size being computed. - **Pseudo-elements** proxy `LocalName`/`GetAttribute` to their host. Guard tag-based special cases (`ResolveFormControlKind`, `TryResolveImage`, `TryResolveInlineSvg`, `` checks) with `IPseudoElement`; replaced-element checks go through `IsReplacedElement`, which judges a pseudo only by an image in its own `content`. A pseudo's `TextContent` (and its host's) never includes generated content; read text from the render tree, as the inline collector does. diff --git a/CHANGELOG.md b/CHANGELOG.md index 555da52..d642a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +# 0.9.2 + +Released on Sunday, November 1 2026. + +- Improved geometry reads after a change confined to an out-of-flow box (e.g. a script showing a hidden dropdown with `position: absolute`, measuring it, and hiding it again): only that box is laid out again, not the whole document (dev.to's scroll handler measuring its 60 dropdowns went from 64 s to under 3 s) +- Improved geometry reads to keep the layout when the page scrolls or the pointer moves without changing the hovered element, and `scrollTop`/`scrollLeft`/`scrollY` to not lay out the document +- Improved `IDomHarness.UpdateFrame` to show hidden absolutely positioned children of flex and grid containers without a full layout, too +- Improved geometry reads after a frame of an interactive document, which are answered from the frame's layout instead of laying out the document again +- Improved geometry reads and `IDomHarness.UpdateFrame` after changes of in-flow blocks (e.g. the text of a title, the color of an article): the block is laid out again on its own when it stays the same from outside (its box, its intrinsic widths where a flex or grid item is sized by them, its margins, no floats beside the boxes after it), else the next blocks around it are tried before a full layout (dev.to: 34ms instead of 1.2s for a changed title) +- Improved laying out a box on its own to reuse what the layout resolved from the page (e.g. the page styles of inline SVG icons, which built a style collection of their own every time) +- Improved changes of the content of an element on pages with `:has()` selectors, which needed a full layout; only a change flipping the match of a `:has()` on the element or around it does now +- Fixed `getBoundingClientRect`, `getClientRects`, and `caretPositionFromPoint` ignoring the scroll position of the page; boxes in fixed boxes stay, and sticky boxes stick for it +- Fixed the content of a stuck sticky box (e.g. the links of a sticky header) being hit tested and measured where it would be without sticking +- Fixed geometry reads measuring text with another measurer than painting (e.g. images in a line off by a tenth of a pixel) +- Fixed `right` and `bottom` of fixed boxes being ignored (e.g. a button fixed at the bottom right was placed at the top left) +- Fixed absolutely positioned and fixed boxes of an auto height placed by `bottom` staying at the top; they are measured first +- Fixed scroll containers other than the page (`overflow: auto`, `scroll`, `hidden`) not moving their content when scrolled, in painting, hit testing, and geometry (`getBoundingClientRect`, `scrollIntoView`) +- Fixed a box laid out on its own inside a scrolled container (other than the page) being measured where it would be unscrolled +- Fixed changes of style sheets (the text of a `style` element, adding or removing `style` and `link` elements) inside a box out of the flow being taken for changes of that box only +- Fixed content with a negative `z-index` inside a scroll container (or any box clipping its overflow) being neither clipped nor scrolled by it +- Fixed absolutely positioned children of grid containers being laid out as grid items (taking a cell and sizing the tracks); they are placed against the grid's padding box, or the grid area of their `grid-column`/`grid-row`/`grid-area` lines (CSS Grid 1 §9.1) +- Fixed grid items with a `z-index` painting in document order +- Fixed `scrollHeight` and `scrollWidth` counting the content clipped by scroll containers inside, and boxes in fixed boxes +- Fixed the text of an absolutely positioned box moving the baseline of its inline-block (e.g. a dropdown inside an inline-block pushing the following lines down) +- Fixed checkboxes, radios and other form controls as flex items in a row (e.g. a checkbox beside its label in a `display: flex` label) getting no width, so they were not painted +- Added `IDomHarness.UpdateFrame`, bringing the last frame up to date without a layout of the whole document when the changes since were confined to boxes out of the flow: their commands are replaced, removed, or inserted where a box shown since paints (e.g. on dev.to showing a profile dropdown or changing the fixed header takes about 50 ms instead of 1.1 s, with the same pixels), and a frame stays as it is when they were not rendered then and are not now (e.g. a dropdown shown, measured and hidden again) + # 0.9.1 Released on Friday, October 30 2026. diff --git a/build/Build.cs b/build/Build.cs index 44cfc56..39ab1c2 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -220,9 +220,9 @@ protected override void OnBuildInitialized() .DependsOn(RunUnitTests) .Executes(() => { + // In CI this is the short-lived key issued by NuGet trusted publishing (NuGet/login). var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); - if (apiKey.IsNullOrEmpty()) { throw new BuildAbortedException("Could not resolve the NuGet API key."); diff --git a/src/AngleSharp.Renderer.Docs/package.json b/src/AngleSharp.Renderer.Docs/package.json index c2b60ee..8c1f235 100644 --- a/src/AngleSharp.Renderer.Docs/package.json +++ b/src/AngleSharp.Renderer.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/renderer", - "version": "0.9.0", + "version": "0.9.2", "preview": true, "description": "The doclet for the AngleSharp.Renderer documentation.", "keywords": [ diff --git a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs index bc69ab2..0c29f8e 100644 --- a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs +++ b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs @@ -48,6 +48,28 @@ public async Task GetBoundingClientRect_AndClientMetrics_AreDerivedFromBorderBox Assert.Equal(30, element.GetScrollHeight()); } + [Fact] + public async Task AbsolutelyPositionedContent_DoesNotMoveTheBaselineOfItsInlineBlock() + { + var document = await ParseAsync(""" + + + +

Card +

+
After
+ + + """); + var after = document.QuerySelector("#after")!; + var top = after.GetBoundingClientRect().Top; + + document.QuerySelector("#dropdown")!.SetAttribute("style", "position:absolute; top:100%; left:0; font-size:30px"); + document.Context.GetDomHarness().PaintToPng(); + + Assert.Equal(top, after.GetBoundingClientRect().Top, 3); + } + [Fact] public async Task GetClientRects_ReturnsEmpty_WhenElementHasNoLayoutBox() { diff --git a/src/AngleSharp.Renderer.Tests/FormControlTests.cs b/src/AngleSharp.Renderer.Tests/FormControlTests.cs index 9ebbb61..2df4dc9 100644 --- a/src/AngleSharp.Renderer.Tests/FormControlTests.cs +++ b/src/AngleSharp.Renderer.Tests/FormControlTests.cs @@ -109,6 +109,23 @@ public async Task CheckedControls_UseTheDefaultAccentColor(string type) Assert.Contains(displayList.Commands.OfType(), command => command.Color.Equals(DefaultAccent)); } + [Theory] + [InlineData("
{0}Label
")] + [InlineData("")] + [InlineData("
{0}Label
")] + [InlineData("
{0}
")] + [InlineData("
{0}Label
")] + public async Task CheckedControls_ArePaintedAsFlexItems(string container) + { + // found in the browser's settings page: checkboxes beside their label in a flex row vanished + foreach (var type in new[] { "checkbox", "radio" }) + { + var displayList = await LayoutAsync(string.Format(container, $"")); + + Assert.Contains(displayList.Commands.OfType(), command => command.Color.Equals(DefaultAccent)); + } + } + [Theory] [InlineData("checkbox", "checked")] [InlineData("radio", "checked")] diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index acea651..3167069 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -645,6 +645,66 @@ public async Task BuildDisplayList_GridItemSpanningAFlexibleRowDoesNotGrowTheOth Assert.Equal(40f, FillOf(displayList, new RenderColor(0x11, 0x22, 0x33)).Y); } + [Fact] + public async Task BuildDisplayList_AbsolutelyPositionedGridChildrenTakeNoCellAndArePlacedInThePaddingBox() + { + var displayList = await LayoutAsync(""" + +
+
+
+
+
+
+
+ + """); + + Assert.Equal(new RenderRect(15, 15, 100, 30), FillOf(displayList, new RenderColor(0x00, 0xaa, 0x00))); + Assert.Equal(new RenderRect(115, 15, 100, 40), FillOf(displayList, new RenderColor(0x00, 0x00, 0xaa))); + Assert.Equal(new RenderRect(300, 10, 20, 20), FillOf(displayList, new RenderColor(0xaa, 0x00, 0x00))); + // without insets, at the start of the content box + Assert.Equal(new RenderRect(15, 15, 20, 20), FillOf(displayList, new RenderColor(0xaa, 0xaa, 0x00))); + Assert.Equal(70f, FillOf(displayList, new RenderColor(0x33, 0x33, 0x33)).Y); + } + + [Fact] + public async Task BuildDisplayList_AbsolutelyPositionedGridChildrenArePlacedInTheirGridArea() + { + var displayList = await LayoutAsync(""" + +
+
+
+
+
+
+ + """); + + Assert.Equal(new RenderRect(110, 55, 20, 20), FillOf(displayList, new RenderColor(0xaa, 0x00, 0x00))); + // the lines left out are the padding edges + Assert.Equal(new RenderRect(80, 0, 20, 20), FillOf(displayList, new RenderColor(0x00, 0xaa, 0x00))); + Assert.Equal(new RenderRect(480, 0, 20, 20), FillOf(displayList, new RenderColor(0xaa, 0xaa, 0x00))); + Assert.Equal(new RenderRect(0, 80, 20, 20), FillOf(displayList, new RenderColor(0x00, 0x00, 0xaa))); + } + + [Fact] + public async Task BuildDisplayList_GridItemsPaintInTheOrderOfTheirZIndex() + { + var displayList = await LayoutAsync(""" + +
+
+
+
+ + """); + + var fills = displayList.Commands.OfType().Select(command => command.Color).ToList(); + Assert.True(fills.IndexOf(new RenderColor(0xaa, 0x00, 0x00)) > fills.IndexOf(new RenderColor(0x00, 0xaa, 0x00))); + } + [Fact] public async Task BuildDisplayList_GridTrackListFromACustomPropertyIsUsed() { diff --git a/src/AngleSharp.Renderer.Tests/IncrementalLayoutTests.cs b/src/AngleSharp.Renderer.Tests/IncrementalLayoutTests.cs new file mode 100644 index 0000000..6125b0b --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/IncrementalLayoutTests.cs @@ -0,0 +1,462 @@ +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Dom; + +namespace AngleSharp.Renderer.Tests; + +/// +/// A change confined to an out-of-flow box (or an in-flow block staying the same from outside) +/// only lays out that box again (see HtmlRenderer.LayoutSnapshot); the geometry read afterwards +/// has to be the one a full layout gives, also where the change is not confined and the full +/// layout is the one taken. +/// +public sealed class IncrementalLayoutTests +{ + private const string Page = """ + + + + + +

Before

+ Card text Dropdown contenttail +
Flow
+ +
x1
+ + + """; + + [Fact] + public async Task ShowingAHiddenDropdownGivesTheGeometryOfAFullLayout() + { + var (document, harness) = await ParseAsync(Page); + var dropdown = document.GetElementById("dropdown")!; + _ = dropdown.GetBoundingClientRect(); + + dropdown.SetAttribute("style", "display: block; opacity: 0"); + + AssertSameAsFullLayout(document, harness, "dropdown", "inner", "card", "flow"); + Assert.True(document.GetElementById("dropdown")!.GetBoundingClientRect().Height > 0); + } + + [Fact] + public async Task MeasuringDropdownsInARowGivesTheGeometryOfFullLayouts() + { + var (document, harness) = await ParseAsync(Page); + var dropdown = document.GetElementById("dropdown")!; + var rects = new List(); + + // what dev.to does for each of its dropdowns on scroll + for (var i = 0; i < 3; i++) + { + dropdown.ClassList.Remove("reverse"); + dropdown.SetAttribute("style", "opacity: 0; display: block"); + rects.Add(Describe(dropdown)); + dropdown.ClassList.Add("reverse"); + rects.Add(Describe(dropdown)); + dropdown.RemoveAttribute("style"); + rects.Add(Describe(dropdown)); + } + + harness.PaintToPng(); + dropdown.ClassList.Remove("reverse"); + dropdown.SetAttribute("style", "opacity: 0; display: block"); + var full = Describe(dropdown); + + Assert.Equal(full, rects[0]); + Assert.NotEqual(rects[0], rects[1]); + Assert.Equal("0.000,0.000,0.000,0.000", rects[2]); + Assert.Equal(rects[0], rects[3]); + } + + [Fact] + public async Task ChangingTheTextOfAnOutOfFlowBoxGivesTheGeometryOfAFullLayout() + { + var (document, harness) = await ParseAsync(Page); + _ = document.GetElementById("label")!.GetBoundingClientRect(); + + document.GetElementById("label")!.TextContent = "a much longer label"; + document.GetElementById("fixed")!.SetAttribute("style", "position: fixed; right: 10px; bottom: 10px; height: 10px;"); + + AssertSameAsFullLayout(document, harness, "fixed", "label", "flow"); + } + + [Fact] + public async Task ChangingAClassASiblingSelectorRefersToGivesTheGeometryOfAFullLayout() + { + var (document, harness) = await ParseAsync(Page); + var flow = document.GetElementById("flow")!; + var before = flow.GetBoundingClientRect(); + _ = document.GetElementById("next")!.GetBoundingClientRect(); + + flow.ClassList.Add("open"); + + Assert.Equal(before.Top, flow.GetBoundingClientRect().Top); + AssertSameAsFullLayout(document, harness, "next", "flow"); + } + + [Fact] + public async Task MovingABoxIntoTheFlowGivesTheGeometryOfAFullLayout() + { + var (document, harness) = await ParseAsync(Page); + var flowTop = document.GetElementById("flow")!.GetBoundingClientRect().Top; + + document.GetElementById("dropdown")!.SetAttribute("style", "display: block; position: static"); + + Assert.True(document.GetElementById("flow")!.GetBoundingClientRect().Top > flowTop); + AssertSameAsFullLayout(document, harness, "dropdown", "card", "flow", "next"); + } + + [Theory] + [InlineData("wide")] + [InlineData("other")] + public async Task ChangingAClassAnAttributeSelectorOfASiblingSelectorRefersToGivesTheGeometryOfAFullLayout(string token) + { + var (document, harness) = await ParseAsync(Page); + var dropdown = document.GetElementById("dropdown")!; + dropdown.SetAttribute("style", "display: block"); + var tail = Describe(document.GetElementById("tail")!); + + dropdown.ClassList.Add(token); + + Assert.Equal(token == "wide", tail != Describe(document.GetElementById("tail")!)); + AssertSameAsFullLayout(document, harness, "dropdown", "tail", "card", "flow"); + } + + [Fact] + public async Task AFrameStaysAsItIsWhenAHiddenBoxIsShownMeasuredAndHiddenAgain() + { + var (document, harness) = await ParseAsync(Page); + var dropdown = document.GetElementById("dropdown")!; + var frame = harness.PrepareFrame(); + Assert.Same(frame, harness.UpdateFrame()); + + dropdown.SetAttribute("style", "display: block"); + _ = dropdown.GetBoundingClientRect(); + dropdown.ClassList.Add("reverse"); + _ = dropdown.GetBoundingClientRect(); + dropdown.RemoveAttribute("style"); + + Assert.Same(frame, harness.UpdateFrame()); + } + + [Theory] + [InlineData("flow", "class", "open")] + [InlineData("tail", "style", "font-size: 30px")] + public async Task AFrameNeedsALayoutAfterOtherChanges(string id, string name, string value) + { + var (document, harness) = await ParseAsync(Page); + harness.PrepareFrame(); + + document.GetElementById(id)!.SetAttribute(name, value); + + Assert.Null(harness.UpdateFrame()); + } + + [Theory] + [InlineData("position: fixed; right: 20px; bottom: 30px; width: 80px; height: 40px; background: rgb(0, 128, 0)", false)] + [InlineData("display: none", false)] + [InlineData("position: fixed; right: 20px; bottom: 30px; width: 80px; height: 40px; background: rgb(0, 128, 0)", true)] + public async Task APatchedFrameShowsWhatALaidOutFrameShows(string style, bool measureFirst) + { + var (document, harness) = await ParseAsync(Page); + var box = document.GetElementById("fixed")!; + harness.PrepareFrame(); + + box.SetAttribute("style", style); + document.GetElementById("label")!.TextContent = "changed"; + + if (measureFirst) + { + _ = box.GetBoundingClientRect(); + } + + var patched = harness.UpdateFrame(); + Assert.NotNull(patched); + var expected = harness.PrepareFrame(); + + Assert.Equal(expected.Rasterize().Data, patched!.Rasterize().Data); + Assert.Equal(expected.HitTestMap.HitTest(330, 250)?.Element, patched.HitTestMap.HitTest(330, 250)?.Element); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ChangingStyleSheetsGivesTheGeometryOfAFullLayout(bool addsSheetToOutOfFlowBox) + { + var (document, harness) = await ParseAsync(Page); + var flow = document.GetElementById("flow")!; + var top = flow.GetBoundingClientRect().Top; + + if (addsSheetToOutOfFlowBox) + { + var sheet = document.CreateElement("style"); + sheet.TextContent = "#flow { margin-top: 50px }"; + document.GetElementById("fixed")!.AppendChild(sheet); + } + else + { + document.Head!.QuerySelector("style")!.TextContent += "#flow { margin-top: 50px }"; + } + + Assert.True(flow.GetBoundingClientRect().Top > top); + AssertSameAsFullLayout(document, harness, "flow", "next", "fixed"); + } + + [Theory] + [InlineData("show")] + [InlineData("show, measure, hide, show")] + [InlineData("inner, outer")] + [InlineData("outer, inner")] + [InlineData("show, inner, hide")] + public async Task APatchedFrameShowsWhatALaidOutFrameShowsAfter(string changes) + { + var (document, harness) = await ParseAsync(Page); + var dropdown = document.GetElementById("dropdown")!; + harness.PrepareFrame(); + + foreach (var change in changes.Split(", ")) + { + switch (change) + { + case "show": + dropdown.SetAttribute("style", "display: block; background: rgb(0, 0, 255)"); + break; + case "hide": + dropdown.RemoveAttribute("style"); + break; + case "measure": + _ = dropdown.GetBoundingClientRect(); + break; + case "inner": + document.GetElementById("badge")!.TextContent = "42"; + _ = document.GetElementById("badge")!.GetBoundingClientRect(); + break; + case "outer": + document.GetElementById("fixed")!.SetAttribute("style", "position: fixed; right: 10px; bottom: 10px; width: 60px; height: 20px; background: rgb(0, 128, 0)"); + _ = document.GetElementById("fixed")!.GetBoundingClientRect(); + break; + } + } + + var patched = harness.UpdateFrame(); + Assert.NotNull(patched); + var expected = harness.PrepareFrame(); + + Assert.Equal(expected.Rasterize().Data, patched!.Rasterize().Data); + } + + [Theory] + [InlineData(1, "block")] + [InlineData(2, "block")] + [InlineData(3, "block")] + [InlineData(1, "flex")] + [InlineData(3, "flex")] + [InlineData(1, "grid")] + [InlineData(3, "grid")] + public async Task AShownBoxPaintsInTheOrderOfItsZIndex(int zIndex, string display) + { + var (document, harness) = await ParseAsync($$""" + + +
+ + """); + harness.PrepareFrame(); + + document.GetElementById("shown")!.SetAttribute("style", $"z-index: {zIndex}; background: rgb(0, 0, 255); top: 10px"); + + var patched = harness.UpdateFrame(); + Assert.NotNull(patched); + Assert.Equal(harness.PrepareFrame().Rasterize().Data, patched!.Rasterize().Data); + } + + [Fact] + public async Task APatchedBoxInAScrolledContainerIsScrolledWithIt() + { + var (document, harness) = await ParseAsync(""" + + +
+ + """); + document.GetElementById("list")!.SetScrollTop(60); + harness.PrepareFrame(); + + document.GetElementById("box")!.SetAttribute("style", "background: rgb(0, 0, 255)"); + + var incremental = Describe(document.GetElementById("box")!); + var patched = harness.UpdateFrame(); + Assert.NotNull(patched); + Assert.Equal(harness.PrepareFrame().Rasterize().Data, patched!.Rasterize().Data); + Assert.Equal("0.000,20.000,50.000,30.000", incremental); + } + + private const string FlowPage = """ + + + + + +

Intro

+

First paragraph

Second word

+

Boxed

+
Beside
+

After

+ + + """; + + public static TheoryData> InFlowChanges => new() + { + // the same box from outside: laid out on its own + { "the text of a word", true, document => document.GetElementById("word")!.TextContent = "text" }, + { "a class changing the color", true, document => document.GetElementById("second")!.ClassName = "hot" }, + { "a box growing in a box of a fixed height", true, document => document.GetElementById("boxed")!.ClassName = "tall" }, + { "a box shown in a box of a fixed height", true, document => document.GetElementById("hidden")!.RemoveAttribute("style") }, + { "text added to a paragraph", true, document => document.GetElementById("second")!.AppendChild(document.CreateTextNode("!")) }, + // moving the boxes after it: a full layout + { "a box growing", false, document => document.GetElementById("second")!.ClassName = "tall" }, + { "a box shown", false, document => document.GetElementById("box")!.SetAttribute("style", "height: auto") }, + { "a margin the parent took", false, document => document.GetElementById("first")!.SetAttribute("style", "margin-top: 30px") }, + { "a counter counted through the document", false, document => document.GetElementById("second")!.SetAttribute("style", "counter-increment: item") }, + { "a float beside the boxes after it", false, document => document.GetElementById("float")!.SetAttribute("style", "float: left; width: 50px; height: 80px") }, + }; + + [Theory] + [MemberData(nameof(InFlowChanges))] + public async Task InFlowChangesGiveTheGeometryAndFrameOfAFullLayout(string change, bool isPatched, Action apply) + { + string[] ids = ["intro", "outer", "first", "second", "word", "box", "boxed", "hidden", "floats", "float", "after"]; + var (document, harness) = await ParseAsync(FlowPage); + harness.PrepareFrame(); + + apply(document); + + var incremental = ids.Select(id => Describe(document.GetElementById(id)!)).ToList(); + var patched = harness.UpdateFrame(); + Assert.True(isPatched == patched is not null, change); + var expected = harness.PrepareFrame(); + Assert.Equal(ids.Select(id => Describe(document.GetElementById(id)!)), incremental); + + if (patched is not null) + { + Assert.Equal(expected.Rasterize().Data, patched.Rasterize().Data); + } + } + + [Theory] + [InlineData("display: flex", "the text of a label", true)] + [InlineData("display: flex", "a shorter label", true)] + [InlineData("display: grid; grid-template-columns: auto 1fr", "the text of a label", true)] + [InlineData("display: grid; grid-template-columns: auto 1fr", "a shorter label", true)] + [InlineData("display: flex; align-items: baseline", "a shorter label", true)] + public async Task ChangesInFlexAndGridItemsGiveTheGeometryAndFrameOfAFullLayout(string display, string change, bool isPatched) + { + string[] ids = ["row", "item", "label", "main", "text", "after"]; + var (document, harness) = await ParseAsync($$""" + + +

Label text

Main text

After

+ + """); + harness.PrepareFrame(); + + document.GetElementById(change == "a shorter label" ? "label" : "text")!.TextContent = change == "a shorter label" ? "Label" : "Main words"; + + var incremental = ids.Select(id => Describe(document.GetElementById(id)!)).ToList(); + var patched = harness.UpdateFrame(); + Assert.Equal(isPatched, patched is not null); + var expected = harness.PrepareFrame(); + Assert.Equal(ids.Select(id => Describe(document.GetElementById(id)!)), incremental); + + if (patched is not null) + { + Assert.Equal(expected.Rasterize().Data, patched.Rasterize().Data); + } + } + + [Theory] + [InlineData("plain", "text", true)] + [InlineData("1", "text", false)] + [InlineData("!", "other", false)] + [InlineData("plain", "other", true)] + public async Task ContentChangesGiveTheGeometryAndFrameOfAFullLayoutWithRelationalSelectors(string html, string parentId, bool isPatched) + { + string[] ids = ["card", "text", "other", "after"]; + var (document, harness) = await ParseAsync(""" + + +

Text

Other

After

+ + """); + harness.PrepareFrame(); + + document.GetElementById(parentId)!.InnerHtml += html; + + var incremental = ids.Select(id => Describe(document.GetElementById(id)!)).ToList(); + var patched = harness.UpdateFrame(); + Assert.Equal(isPatched, patched is not null); + var expected = harness.PrepareFrame(); + Assert.Equal(ids.Select(id => Describe(document.GetElementById(id)!)), incremental); + + if (patched is not null) + { + Assert.Equal(expected.Rasterize().Data, patched.Rasterize().Data); + } + } + + private static void AssertSameAsFullLayout(IDocument document, IDomHarness harness, params string[] ids) + { + var incremental = ids.Select(id => Describe(document.GetElementById(id)!)).ToList(); + + // anything invalidating the paint drops the metrics + harness.PaintToPng(); + var full = ids.Select(id => Describe(document.GetElementById(id)!)).ToList(); + + Assert.Equal(full, incremental); + } + + // Two full layouts already differ in the last bits of a float (e.g. 21.6 and 21.599998 for + // the same line, depending on what else is on it), so the geometry is compared in thousandths. + private static string Describe(IElement element) + { + var rect = element.GetBoundingClientRect(); + return FormattableString.Invariant($"{rect.Left:F3},{rect.Top:F3},{rect.Width:F3},{rect.Height:F3}"); + } + + private static async Task<(IDocument Document, IDomHarness Harness)> ParseAsync(string html) + { + var context = BrowsingContext.New(Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice + { + ViewPortWidth = 400, + ViewPortHeight = 300, + DeviceWidth = 400, + DeviceHeight = 300, + FontSize = 16, + })); + var document = await context.OpenAsync(request => request.Content(html)); + return (document, context.GetDomHarness()); + } +} diff --git a/src/AngleSharp.Renderer.Tests/ScrolledGeometryTests.cs b/src/AngleSharp.Renderer.Tests/ScrolledGeometryTests.cs new file mode 100644 index 0000000..855c9f1 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/ScrolledGeometryTests.cs @@ -0,0 +1,254 @@ +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Dom; + +namespace AngleSharp.Renderer.Tests; + +/// +/// Geometry in the viewport follows the page's scroll position: the page moves, fixed boxes stay, +/// and sticky boxes stick, together with their content. +/// +public sealed class ScrolledGeometryTests +{ + private const string Page = """ + + + + + +
Intro
+ +
+
+
+ + + """; + + [Theory] + [InlineData("position: fixed; bottom: 10px; right: 10px", 400d - 10d - 60d, 300d - 10d - 40d)] + [InlineData("position: absolute; bottom: 0; left: 0", 0d, 1000d - 40d)] + public async Task BoxesOfAnAutoHeightArePlacedByTheirBottom(string style, double left, double top) + { + var document = await ParseAsync($$""" + + +
+ + """); + + var rect = document.GetElementById("toast")!.GetBoundingClientRect(); + + Assert.Equal(40d, rect.Height, 3); + Assert.Equal(left, rect.Left, 3); + Assert.Equal(top, rect.Top, 3); + } + + [Theory] + [InlineData("display: block")] + [InlineData("display: flex; flex-direction: column")] + public async Task ScrollContainersMoveTheirContent(string display) + { + var document = await ParseAsync($$""" + + +
{{string.Concat(Enumerable.Range(0, 10).Select(i => $"
Item {i}
"))}}
+ + """); + var list = document.GetElementById("list")!; + + list.SetScrollTop(60); + + Assert.Equal(60d, list.GetScrollTop()); + Assert.Equal(300, list.GetScrollHeight()); + Assert.Equal(20d, document.GetElementById("item2")!.GetBoundingClientRect().Top, 3); + Assert.Equal(20d, list.GetBoundingClientRect().Top, 3); + + list.SetScrollTop(1000); + Assert.Equal(200d, list.GetScrollTop()); + + document.GetElementById("item5")!.ScrollIntoView(); + Assert.Equal(150d, list.GetScrollTop()); + Assert.Equal(20d, document.GetElementById("item5")!.GetBoundingClientRect().Top, 3); + + var hit = document.Context.GetDomHarness().PrepareFrame().HitTestMap.HitTest(5, 25); + Assert.Equal("item5", hit!.Value.Element.Id); + } + + [Fact] + public async Task NestedScrollContainersOnlyAddTheirBoxToTheScrollableOverflow() + { + var document = await ParseAsync(""" + + +
+ + """); + + Assert.Equal(150, document.GetElementById("outer")!.GetScrollHeight()); + Assert.Equal(1000, document.GetElementById("inner")!.GetScrollHeight()); + } + + [Fact] + public async Task ScrollContainersClipAndScrollTheirContentBehindThem() + { + var document = await ParseAsync(""" + + +
+ + """); + var harness = document.Context.GetDomHarness(); + + var before = harness.PrepareFrame().Rasterize(); + document.GetElementById("list")!.SetScrollTop(150); + var after = harness.PrepareFrame().Rasterize(); + + // RGBA: clipped at (5, 155) before, scrolled to the top of the list after + Assert.Equal((255, 255), (before.Data[(155 * before.RowBytes) + 20], before.Data[(155 * before.RowBytes) + 21])); + Assert.Equal((255, 0), (after.Data[(5 * after.RowBytes) + 20], after.Data[(5 * after.RowBytes) + 21])); + } + + [Fact] + public async Task ScrollContainersPaintTheirContentScrolled() + { + var document = await ParseAsync(""" + + +
+ + """); + var harness = document.Context.GetDomHarness(); + + var before = harness.PrepareFrame().Rasterize(); + document.GetElementById("list")!.SetScrollTop(150); + var after = harness.PrepareFrame().Rasterize(); + + // RGBA at (5, 45): white before, the red item (at 150, the content scrolls by at most 110) after + var pixel = (45 * after.RowBytes) + (5 * 4); + Assert.Equal((255, 255), (before.Data[pixel], before.Data[pixel + 1])); + Assert.Equal((255, 0), (after.Data[pixel], after.Data[pixel + 1])); + } + + [Fact] + public async Task ThePageMovesWithTheScrollPosition() + { + var document = await ParseAsync(Page); + document.DocumentElement!.SetScrollTop(300); + + Assert.Equal(200d, document.GetElementById("box")!.GetBoundingClientRect().Top, 3); + Assert.Equal(-300d, document.GetElementById("intro")!.GetBoundingClientRect().Top, 3); + } + + [Fact] + public async Task FixedBoxesStayWhereTheyAre() + { + var document = await ParseAsync(Page); + var top = document.GetElementById("inner")!.GetBoundingClientRect().Top; + + document.DocumentElement!.SetScrollTop(300); + + Assert.Equal(260d, document.GetElementById("fixed")!.GetBoundingClientRect().Top, 3); + Assert.Equal(top, document.GetElementById("inner")!.GetBoundingClientRect().Top, 3); + } + + [Fact] + public async Task StickyBoxesStickWithTheirContent() + { + var document = await ParseAsync(Page); + Assert.Equal(100d, document.GetElementById("link")!.GetBoundingClientRect().Top, 3); + + document.DocumentElement!.SetScrollTop(300); + + Assert.Equal(0d, document.GetElementById("header")!.GetBoundingClientRect().Top, 3); + var link = document.GetElementById("link")!.GetBoundingClientRect(); + Assert.Equal(0d, link.Top, 3); + Assert.Equal(10d, link.Left, 3); + } + + [Fact] + public async Task ContentOfAStuckStickyBoxIsHitWhereItIsPainted() + { + var document = await ParseAsync(Page); + document.DocumentElement!.SetScrollTop(300); + + var frame = document.Context.GetDomHarness().PrepareFrame(); + var hit = frame.HitTestMap.HitTest(20, 10); + + Assert.NotNull(hit); + Assert.Equal("link", hit!.Value.Element.Id); + } + + [Fact] + public async Task CaretPositionsAreLookedUpInTheViewport() + { + var document = await ParseAsync(Page); + document.DocumentElement!.SetScrollTop(300); + + var caret = document.CaretPositionFromPoint(20, 220); + + Assert.NotNull(caret); + Assert.Equal("box", (caret!.OffsetNode as IElement)?.Id); + } + + [Fact] + public async Task TheLayoutOfAScrolledFrameGivesTheGeometryOfAFullLayout() + { + var document = await ParseAsync(Page); + var harness = document.Context.GetDomHarness(); + document.DocumentElement!.SetScrollTop(300); + string[] ids = ["intro", "header", "link", "content", "box", "fixed", "inner"]; + + harness.PrepareFrame(); + var fromFrame = ids.Select(id => Describe(document.GetElementById(id)!)).ToList(); + harness.PaintToPng(); + var fromCapture = ids.Select(id => Describe(document.GetElementById(id)!)).ToList(); + + Assert.Equal(fromCapture, fromFrame); + } + + [Fact] + public async Task ChangesAfterAScrolledFrameGiveTheGeometryOfAFullLayout() + { + var document = await ParseAsync(Page); + var harness = document.Context.GetDomHarness(); + document.DocumentElement!.SetScrollTop(300); + harness.PrepareFrame(); + + document.GetElementById("box")!.SetAttribute("style", "top: 650px; width: 80px"); + var incremental = Describe(document.GetElementById("box")!); + harness.PaintToPng(); + + Assert.Equal("0.000,350.000,80.000,40.000", incremental); + Assert.Equal(Describe(document.GetElementById("box")!), incremental); + } + + private static string Describe(IElement element) + { + var rect = element.GetBoundingClientRect(); + return FormattableString.Invariant($"{rect.Left:F3},{rect.Top:F3},{rect.Width:F3},{rect.Height:F3}"); + } + + private static async Task ParseAsync(string html) + { + var context = BrowsingContext.New(Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice + { + ViewPortWidth = 400, + ViewPortHeight = 300, + DeviceWidth = 400, + DeviceHeight = 300, + FontSize = 16, + })); + return await context.OpenAsync(request => request.Content(html)); + } +} diff --git a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj index 62dcadb..7381190 100644 --- a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj +++ b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj @@ -20,8 +20,8 @@ true true snupkg - 1.8.6 - 1.2.2 + 1.9.0 + 1.3.1 diff --git a/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs b/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs index 8b466e1..39723d7 100644 --- a/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs +++ b/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs @@ -20,8 +20,11 @@ public static class DocumentCssomViewExtensions ArgumentNullException.ThrowIfNull(document); var harness = document.Context.GetDomHarness(); + // the point is in the viewport, so are the boxes it is looked up in var metricsMap = harness is InteractiveHtmlRendererState state - ? state.GetLayoutMetrics(document) + ? document.DocumentElement is { } root + ? state.GetLayoutSnapshot(document).ToViewport(state.GetScrollLeft(root, double.MaxValue), state.GetScrollTop(root, double.MaxValue)) + : state.GetLayoutMetrics(document) : HtmlRenderer.CaptureLayoutMetrics(document, harness.RenderDevice); var targetElement = HitTesting.FindTopMostElementAt(metricsMap, x, y); diff --git a/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs index c3c7afc..3da6dbf 100644 --- a/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs +++ b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs @@ -19,8 +19,7 @@ public static IDomRect GetBoundingClientRect(this IElement element) { ArgumentNullException.ThrowIfNull(element); - var metricsMap = GetMetricsMap(element); - if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + if (GetViewportMetrics(element) is not { } metrics) { return new DomRect(); } @@ -36,8 +35,7 @@ public static IDomRectList GetClientRects(this IElement element) { ArgumentNullException.ThrowIfNull(element); - var metricsMap = GetMetricsMap(element); - if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + if (GetViewportMetrics(element) is not { } metrics) { return new DomRectList(); } @@ -122,9 +120,9 @@ public static double GetScrollLeft(this IElement element) { ArgumentNullException.ThrowIfNull(element); - var maxLeft = GetMaxScrollLeft(element); + // clamped when it was set, so reading it (scripts do, e.g. on every scroll) needs no layout var state = GetInteractiveState(element); - return state is null ? 0d : state.GetScrollLeft(element, maxLeft); + return state is null ? 0d : state.GetScrollLeft(element, double.MaxValue); } /// @@ -154,9 +152,9 @@ public static double GetScrollTop(this IElement element) { ArgumentNullException.ThrowIfNull(element); - var maxTop = GetMaxScrollTop(element); + // clamped when it was set, so reading it (scripts do, e.g. on every scroll) needs no layout var state = GetInteractiveState(element); - return state is null ? 0d : state.GetScrollTop(element, maxTop); + return state is null ? 0d : state.GetScrollTop(element, double.MaxValue); } /// @@ -406,6 +404,33 @@ private static int GetRoundedDimension(IElement element, Func metricsMap) { - var current = candidate.ParentElement; + var isClipped = false; - while (current is not null) + for (var current = candidate.ParentElement; current is not null; current = current.ParentElement) { if (ReferenceEquals(current, ancestor)) { - return true; + return !isClipped; } - current = current.ParentElement; + isClipped |= metricsMap.TryGetValue(current, out var metrics) && metrics.Layer.ClipsOverflow; } return false; @@ -526,6 +558,16 @@ private static void ScrollElementIntoView(IElement element, ScrollLogicalPositio var currentLeft = ancestor.GetScrollLeft(); var currentTop = ancestor.GetScrollTop(); + + // the content of a scroll container is laid out where its scroll position moved it, + // the page is laid out unscrolled + if (!ReferenceEquals(ancestor, element.Owner?.DocumentElement)) + { + elementLeft += (float)currentLeft; + elementRight += (float)currentLeft; + elementTop += (float)currentTop; + elementBottom += (float)currentTop; + } var targetLeft = ResolveScrollOffset(currentLeft, elementLeft, elementRight, clientWidth, inline); var targetTop = ResolveScrollOffset(currentTop, elementTop, elementBottom, clientHeight, block); diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Floats.cs b/src/AngleSharp.Renderer/HtmlRenderer.Floats.cs index 69586bf..e4d0ef9 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Floats.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Floats.cs @@ -16,6 +16,8 @@ private sealed class FloatArea { private readonly List _floats = []; + public bool IsEmpty => _floats.Count == 0; + private readonly record struct PlacedFloat(float Left, float Top, float Right, float Bottom, bool IsLeft); /// diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Frames.cs b/src/AngleSharp.Renderer/HtmlRenderer.Frames.cs index c06ec2e..59f1784 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Frames.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Frames.cs @@ -10,45 +10,75 @@ namespace AngleSharp.Renderer; /// public sealed partial class HtmlRenderer { + /// + /// Lays out the document unscrolled for geometry reads, measuring text like the frames do. + /// + internal LayoutSnapshot CaptureLayout(IDocument document, IRenderDevice renderDevice) => + LayoutSnapshot.LayOut(document, renderDevice, _textMeasurer, 0f, 0, isFrame: false, out _); + /// /// Lays out the document for a frame scrolled to . The frame's /// surface extends rows above and below the viewport, so a host /// can scroll it (see ) until the next frame. /// - internal RenderedFrame PrepareFrame(IDocument document, IRenderDevice renderDevice, double scrollTop, int overscan) + internal RenderedFrame PrepareFrame(IDocument document, IRenderDevice renderDevice, double scrollTop, int overscan) => + PrepareFrame(document, renderDevice, scrollTop, overscan, out _); + + /// + /// Lays out the document for a frame (see above), which also gives the layout to answer + /// geometry reads from until the document changes. + /// + internal RenderedFrame PrepareFrame(IDocument document, IRenderDevice renderDevice, double scrollTop, int overscan, out LayoutSnapshot snapshot) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(renderDevice); overscan = Math.Max(0, overscan); - var context = CreateLayoutContext(document, renderDevice, _textMeasurer); - var viewport = new RenderViewport(context.Width, context.Height); - var surface = new RenderViewport(context.Width, context.Height + (2 * overscan)); var scrollOffsetY = (float)scrollTop - overscan; - var capture = new LayoutCapture(); - var previous = s_layoutCapture.Value; - s_layoutCapture.Value = capture; - DisplayList displayList; - IReadOnlyDictionary metrics; + // the whole page is laid out: hit testing below the viewport and the scroll extent need it + snapshot = LayoutSnapshot.LayOut(document, renderDevice, _textMeasurer, scrollOffsetY, 2 * overscan, isFrame: true, out var displayList); + return CreateFrame(document, snapshot, displayList, scrollTop, overscan); + } - try + /// + /// The last frame with the boxes laid out on their own since (see + /// ), or null if it needs a new layout. + /// + internal RenderedFrame? UpdateFrame(IDocument document, RenderedFrame frame, LayoutSnapshot snapshot) + { + if (snapshot.IsUnchangedSinceFrame) { - // the whole page is laid out: hit testing below the viewport and the scroll extent need it - displayList = BuildDisplayList(document, viewport, context, renderDevice, scrollOffsetY, measureFullExtent: true, surfaceExtraHeight: 2 * overscan); - metrics = AddHitTestLayers(capture.Snapshot(), context); + return frame; } - finally + + var displayList = new DisplayList { Fonts = frame.DisplayList.Fonts }; + displayList.InsertRange(0, frame.DisplayList.Commands); + + if (!snapshot.TryPatch(displayList)) { - s_layoutCapture.Value = previous; + return null; } + var updated = CreateFrame(document, snapshot, displayList, frame.ScrollTop, frame.OverscanTop); + updated.IsAnimating = frame.IsAnimating; + return updated; + } + + private RenderedFrame CreateFrame(IDocument document, LayoutSnapshot snapshot, DisplayList displayList, double scrollTop, int overscan) + { + var scrollOffsetY = (float)scrollTop - overscan; + var (width, height) = snapshot.Viewport; + var styleMaps = snapshot.StyleMaps; + var surface = new RenderViewport(width, height + (2 * overscan)); + var metrics = snapshot.LayoutMetrics; + var isScrollShiftable = true; var hasStickyBoxes = false; var contentBottom = 0f; foreach (var (element, elementMetrics) in metrics) { - var position = context.StyleMapCache.TryGetValue(element, out var styleMap) + var position = styleMaps.TryGetValue(element, out var styleMap) ? styleMap.GetValueOrDefault("position")?.Trim() : null; @@ -65,9 +95,9 @@ internal RenderedFrame PrepareFrame(IDocument document, IRenderDevice renderDevi } } - var hitTestMap = HitTestMap.Create(document, metrics, context.StyleMapCache, -overscan); - var contentHeight = Math.Max(context.Height, (double)contentBottom); + var hitTestMap = HitTestMap.Create(document, metrics, styleMaps, -overscan); + var contentHeight = Math.Max(height, (double)contentBottom); // fixed boxes are marked in the display list, sticky ones would have to stick anew - return new RenderedFrame(displayList, _backend, surface, context.Height, scrollTop, overscan, contentHeight, isScrollShiftable, !hasStickyBoxes, hitTestMap); + return new RenderedFrame(displayList, _backend, surface, height, scrollTop, overscan, contentHeight, isScrollShiftable, !hasStickyBoxes, hitTestMap); } } diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs b/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs new file mode 100644 index 0000000..a72b5f3 --- /dev/null +++ b/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs @@ -0,0 +1,1206 @@ +namespace AngleSharp.Renderer; + +using System.Linq; +using System.Text.RegularExpressions; +using AngleSharp.Css; +using AngleSharp.Css.Dom; +using AngleSharp.Css.RenderTree; +using AngleSharp.Dom; +using AngleSharp.Renderer.Rendering; + +/// +/// Incremental layout metrics. Scripts often change a box and read its geometry right after, +/// for many boxes in a row (dev.to shows each of its 60 hidden dropdowns, measures and hides it +/// again on every scroll), and every read after a change used to be a full layout. A change +/// confined to the subtree of a box that does not take part in the layout of anything else (an +/// absolutely positioned box, or one that was and is not rendered) only lays out that subtree +/// again, with what its layout started from in the last full layout. So does a change of a +/// block of an ordinary block flow (see ), as long as it comes out the +/// same from outside: its box, its intrinsic widths where its container is sized by them, and +/// what it leaves for the boxes after it. Else the enclosing block is laid out instead. +/// +public sealed partial class HtmlRenderer +{ + private static readonly Regex s_selectorTokens = new(@"[.#](?:[\w-]|\\.)+|:[\w-]+", RegexOptions.CultureInvariant); + private static readonly Regex s_attributeSelectors = new(@"\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*(?:""([^""]*)""|'([^']*)'|([^\s\]]*))\s*([iIsS])?\s*)?\]", RegexOptions.CultureInvariant); + + // the pseudo-classes depending on attributes (e.g. input:checked + label) + private static readonly HashSet s_attributeStatePseudoClasses = new(StringComparer.OrdinalIgnoreCase) + { + ":checked", ":disabled", ":enabled", ":required", ":optional", ":read-only", ":read-write", ":placeholder-shown", + ":default", ":open", ":indeterminate", ":valid", ":invalid", ":in-range", ":out-of-range", ":target", ":link", + ":any-link", ":lang", ":dir", ":empty", + }; + + /// + /// Where the layout of an out-of-flow (or not rendered) element started in a full layout: + /// the arguments of its call. + /// is the block that holds the box back until its height is known (see + /// ), if it would. Without , + /// the entry is made up from its parent's for a box that + /// never reached its layout (e.g. not rendered in inline content), which is only right for a + /// box placed by its insets alone. An entry is a block of an + /// ordinary block flow (see ), which also needs the margins it + /// collapsed with, and leaves a for the boxes after it. + /// + private sealed record LayoutEntry( + ElementRenderNode Node, + float ContainingX, + float ContainingY, + float ContainingWidth, + float CursorY, + bool TextIndentConsumed, + RenderTextStyle TextStyle, + float MaxY, + float ContainingHeight, + float PositionedContainingX, + float PositionedContainingY, + float PositionedContainingWidth, + float PositionedContainingHeight, + IElement? DeferContainer, + bool HasBox, + bool HasStaticPosition = true, + bool IsInFlow = false, + float PreviousBlockMarginBottom = 0f, + bool SuppressNextBlockTopMargin = false, + FlowResult? Result = null, + bool IsSizedByContent = false); + + /// + /// What an in-flow block left for the boxes after it: where they start, the margin they + /// collapse with, and if it left no floats beside them. + /// + private sealed record FlowResult(float CursorY, float PreviousBlockMarginBottom, bool SuppressNextBlockTopMargin, bool TextIndentConsumed, bool LeftNoFloats); + + /// A child of a flow painted after its in-flow content (positioned or z-indexed). + private sealed record RaisedChild(IElement Element, int ZIndex, RenderCommand First, int Count); + + /// + /// Where the not rendered children of a flow would paint: among its + /// children, in the order they paint, or after , the flow's last command. + /// + private sealed record PaintLayer(IReadOnlyList Raised, RenderCommand End); + + /// + /// What a block gave its children to start their layouts from: its content box, its text + /// style, and the containing block of their absolutely positioned boxes. + /// + private sealed record ContainerEntry( + ElementRenderNode Node, + float ContentX, + float ContentY, + float ContentWidth, + RenderTextStyle TextStyle, + float MaxY, + float ContentHeight, + float PositionedContainingX, + float PositionedContainingY, + float PositionedContainingWidth, + float PositionedContainingHeight, + float PaddingBoxHeight = float.NaN, + bool IsFlow = false); + + private static void RecordLayoutEntry( + LayoutCapture capture, + ElementRenderNode node, + Dictionary styleMap, + float containingX, + float containingY, + float containingWidth, + float cursorY, + float previousBlockMarginBottom, + bool suppressNextBlockTopMargin, + FloatArea floats, + bool textIndentConsumed, + RenderTextStyle textStyle, + LayoutContext context, + float maxY, + bool isPlain, + float containingHeight, + float positionedContainingX, + float positionedContainingY, + float positionedContainingWidth, + float positionedContainingHeight) + { + var hasBox = GeneratesBoxes(node); + + var isInFlow = hasBox && !IsOutOfFlowPositioned(styleMap); + + // only boxes not taking part in the layout of the others, or blocks of a block flow, laid out the ordinary way + if (!isPlain || (isInFlow && !IsInFlowEntry(capture, node, styleMap, floats, context))) + { + capture.Entries!.Remove(node.Ref); + return; + } + + if (isInFlow) + { + capture.Entries![node.Ref] = new LayoutEntry(node, containingX, containingY, containingWidth, cursorY, textIndentConsumed, textStyle, maxY, + containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight, null, HasBox: true, + IsInFlow: true, PreviousBlockMarginBottom: previousBlockMarginBottom, SuppressNextBlockTopMargin: suppressNextBlockTopMargin, + IsSizedByContent: IsSizedByContent(capture, (ElementRenderNode)node.Parent!, context)); + return; + } + + var deferContainer = float.IsNaN(positionedContainingHeight) && + context.PositionedScopes.TryPeek(out var scope) && + scope.Container.Children.Contains(node) ? scope.Container.Ref : null; + + capture.Entries![node.Ref] = new LayoutEntry( + node, + containingX, + containingY, + containingWidth, + cursorY, + textIndentConsumed, + textStyle, + maxY, + containingHeight, + positionedContainingX, + positionedContainingY, + positionedContainingWidth, + positionedContainingHeight, + deferContainer, + hasBox); + } + + /// + /// If a block can be laid out again on its own from where its flow laid it out: a block of + /// the ordinary flow of a block that is one itself (or the root element, or a flex or grid + /// item of one, see ), with no floats beside it. + /// + private static bool IsInFlowEntry(LayoutCapture capture, ElementRenderNode node, Dictionary styleMap, FloatArea floats, LayoutContext context) + { + if (!floats.IsEmpty || node.Parent is not ElementRenderNode parent || IsFloated(styleMap) || IsStickyPositioned(styleMap) || + ClassifyFlowChild(node, context) != FlowChildKind.Block) + { + return false; + } + + // laid out through the parent's block flow, in this layout + if (capture.Containers is not { } containers || !containers.TryGetValue(parent.Ref, out var container) || !container.IsFlow || + !ReferenceEquals(container.Node, parent)) + { + return false; + } + + return IsChained(capture, parent); + } + + // a box whose blocks have in-flow entries: the root element, a block with one, or a link + private static bool IsChained(LayoutCapture capture, ElementRenderNode node) => + node.Ref.ParentElement is null || capture.Links?.Contains(node.Ref) == true || + capture.Entries!.TryGetValue(node.Ref, out var entry) && entry.IsInFlow && ReferenceEquals(entry.Node, node); + + // If the size of a parent (or of a box around it) depends on the content of its blocks, which + // then have to keep their intrinsic widths (and so their contributions to the size) when laid + // out on their own: in a flex or grid item, or in a block of a width like `fit-content`. + private static bool IsSizedByContent(LayoutCapture capture, ElementRenderNode parent, LayoutContext context) => + capture.Links?.Contains(parent.Ref) == true || + GetOrCreateStyleMap(context, parent.Ref, parent.ComputedStyle).TryGetValue("width", out var width) && width.Contains("content", StringComparison.OrdinalIgnoreCase) || + capture.Entries!.TryGetValue(parent.Ref, out var entry) && entry.IsSizedByContent; + + /// + /// Lets a flex or grid item of a chained container pass the in-flow entries on to its + /// content (see ), unless it is aligned by its baseline, which a + /// block inside could move without changing its box. + /// + private static void RecordLink(ElementRenderNode container, ElementRenderNode item, Dictionary containerStyleMap, Dictionary itemStyleMap, LayoutContext context) + { + if (s_layoutCapture.Value is not { Links: { } links } capture) + { + return; + } + + if (IsChained(capture, container) && !IsBaseline(containerStyleMap.GetValueOrDefault("align-items")) && !IsBaseline(itemStyleMap.GetValueOrDefault("align-self"))) + { + links.Add(item.Ref); + } + else + { + links.Remove(item.Ref); + } + + static bool IsBaseline(string? value) => value?.Contains("baseline", StringComparison.OrdinalIgnoreCase) == true; + } + + /// + /// The layout metrics of a document, kept up to date by laying out again only the boxes that + /// changed, as long as nothing else can have changed (see ). + /// + internal sealed class LayoutSnapshot + { + private readonly IDocument _document; + private readonly IRenderDevice _renderDevice; + // the caches of the last full layout: still right for everything outside the changed + // subtrees, whose entries are removed before they are laid out again + private readonly LayoutContext _context; + private readonly Dictionary _entries; + private readonly Dictionary _containers; + private readonly HashSet _links; + // what the layout resolved from the page (e.g. the page styles of inline SVGs), which + // stays right outside the changed subtrees as well + private readonly LayoutPass _pass; + private readonly Dictionary _stickies; + // the boxes as laid out, scrolled by _layoutScrollY (a frame's layout is scrolled), and + // the same in page coordinates (the same instance when the layout is unscrolled) + private readonly float _layoutScrollY; + private readonly Dictionary _metrics; + private readonly Dictionary _pageMetrics; + // for a frame's layout: where the boxes are in its display list, and the boxes laid out + // again since (see TryPatch) + private readonly bool _isFrameLayout; + private readonly Dictionary _ranges; + private readonly Dictionary _layers; + // the boxes laid out again since: if they had a box in the frame and have one now, and + // each layout with where the box was in the display list before and its commands after + private readonly Dictionary _rendered = new(ReferenceEqualityComparer.Instance); + private readonly List<(IElement Root, (RenderCommand First, int Count)? Before, IReadOnlyList After)> _relayouts = []; + private SiblingSelectors? _siblingSelectors; + // the elements each compound with `:has()` matched as laid out (null if it cannot be matched) + private readonly List<(string Compound, HashSet? Matches)> _relationalMatches; + + private LayoutSnapshot(IDocument document, IRenderDevice renderDevice, LayoutContext context, LayoutPass pass, LayoutCapture capture, IReadOnlyDictionary metrics, float layoutScrollY, bool isFrameLayout) + { + _pass = pass; + _isFrameLayout = isFrameLayout; + _document = document; + _renderDevice = renderDevice; + _context = context; + _entries = capture.Entries!; + _containers = capture.Containers!; + _links = capture.Links!; + _stickies = capture.Stickies!; + _ranges = capture.Ranges!; + _layers = capture.Layers!; + _layoutScrollY = layoutScrollY; + _metrics = new Dictionary(metrics, ReferenceEqualityComparer.Instance); + _pageMetrics = layoutScrollY == 0f ? _metrics : new Dictionary(_metrics.Count, ReferenceEqualityComparer.Instance); + _relationalMatches = GetSiblingSelectors().RelationalCompounds + .Select(compound => (compound, TryQuerySelectorAll(document, compound))) + .ToList(); + + if (!ReferenceEquals(_pageMetrics, _metrics)) + { + foreach (var (element, elementMetrics) in _metrics) + { + _pageMetrics[element] = ToPage(elementMetrics); + } + } + } + + /// Gets the boxes in page coordinates, i.e., unscrolled (except in fixed boxes). + public IReadOnlyDictionary Metrics => _pageMetrics; + + /// + /// Gets if this is a frame's layout that still looks the same: all boxes laid out again + /// since were not rendered in the frame and are not now. + /// + public bool IsUnchangedSinceFrame => _isFrameLayout && _rendered.Values.All(rendered => !rendered.Before && !rendered.Now); + + /// Gets if this is a frame's layout (see ). + public bool IsFrameLayout => _isFrameLayout; + + /// + /// Takes the boxes laid out again since the frame into its display list, layout by + /// layout: their commands replace those they had, or are inserted where a box shown since + /// paints (see ). A layout of a box inside one laid out later is + /// part of that one already. + /// + /// A copy of the frame's display list (or of the last patched one). + /// True if the display list shows the document now, false if a new frame is needed. + public bool TryPatch(DisplayList displayList) + { + if (!_isFrameLayout) + { + return false; + } + + for (var index = 0; index < _relayouts.Count; index++) + { + var (root, before, after) = _relayouts[index]; + + if (_relayouts.Skip(index + 1).Any(later => later.Root != root && later.Root.Contains(root))) + { + continue; + } + + if (before is { } range) + { + if (IndexOf(displayList, range.First) is not (>= 0 and var start) || start + range.Count > displayList.Commands.Count) + { + return false; + } + + displayList.ReplaceRange(start, range.Count, after); + } + else if (after.Count > 0) + { + if (FindPaintPosition(displayList, root) is not (>= 0 and var position)) + { + return false; + } + + displayList.InsertRange(position, after); + } + } + + // the patched display list is the frame from now on + _rendered.Clear(); + _relayouts.Clear(); + return true; + } + + // Where a box shown since the frame paints: among the children of its flow painted after + // the in-flow content (by z-index, then document order), as they are now, else after the flow. + private int FindPaintPosition(DisplayList displayList, IElement root) + { + var zIndex = _context.StyleMapCache.TryGetValue(root, out var styleMap) ? ParseZIndex(styleMap) : 0; + + if (zIndex < 0 || !_layers.TryGetValue(root, out var layer)) + { + return -1; + } + + var end = -1; + + foreach (var raised in layer.Raised) + { + // laid out again since (or hidden) or as in the frame + var (first, count) = _ranges.TryGetValue(raised.Element, out var range) ? range + : _entries.ContainsKey(raised.Element) ? (null, 0) + : (raised.First, raised.Count); + + if (first is null) + { + continue; + } + + if (IndexOf(displayList, first) is not (>= 0 and var start)) + { + return -1; + } + + if (raised.ZIndex > zIndex || (raised.ZIndex == zIndex && root.CompareDocumentPosition(raised.Element).HasFlag(DocumentPositions.Following))) + { + return start; + } + + end = start + count; + } + + return end >= 0 ? end : IndexOf(displayList, layer.End) is >= 0 and var last ? last + 1 : -1; + } + + private static int IndexOf(DisplayList list, RenderCommand command) + { + var commands = list.Commands; + + for (var index = 0; index < commands.Count; index++) + { + if (ReferenceEquals(commands[index], command)) + { + return index; + } + } + + return -1; + } + + /// Gets the boxes as laid out (scrolled like the layout). + public IReadOnlyDictionary LayoutMetrics => _metrics; + + /// Gets the viewport size the layout was made for. + public (int Width, int Height) Viewport => (_context.Width, _context.Height); + + /// Gets the style maps of the laid out elements. + public IReadOnlyDictionary> StyleMaps => _context.StyleMapCache; + + public static LayoutSnapshot Capture(IDocument document, IRenderDevice renderDevice) => + LayOut(document, renderDevice, s_defaultTextMeasurer, 0f, 0, isFrame: false, out _); + + /// + /// Lays out the whole document scrolled by (unculled, as + /// geometry reads and hit testing need all of it), on a surface that many rows taller + /// than the viewport. + /// + public static LayoutSnapshot LayOut(IDocument document, IRenderDevice renderDevice, ITextMeasurer textMeasurer, float scrollOffsetY, int surfaceExtraHeight, bool isFrame, out DisplayList displayList) + { + var context = CreateLayoutContext(document, renderDevice, textMeasurer); + var viewport = new RenderViewport(context.Width, context.Height); + var capture = new LayoutCapture + { + Entries = new Dictionary(ReferenceEqualityComparer.Instance), + Containers = new Dictionary(ReferenceEqualityComparer.Instance), + Links = new HashSet(ReferenceEqualityComparer.Instance), + Stickies = new Dictionary(ReferenceEqualityComparer.Instance), + Ranges = new Dictionary(ReferenceEqualityComparer.Instance), + Layers = new Dictionary(ReferenceEqualityComparer.Instance), + }; + var pass = new LayoutPass(); + var previous = s_layoutCapture.Value; + s_layoutCapture.Value = capture; + + try + { + displayList = BuildDisplayList(document, viewport, context, renderDevice, scrollOffsetY, measureFullExtent: true, surfaceExtraHeight: surfaceExtraHeight, pass: pass); + return new LayoutSnapshot(document, renderDevice, context, pass, capture, AddHitTestLayers(capture.Snapshot(), context), scrollOffsetY, isFrame); + } + finally + { + s_layoutCapture.Value = previous; + } + } + + private ElementLayoutMetrics ToPage(ElementLayoutMetrics metrics) => + metrics.IsViewportAnchored ? metrics : metrics with { BorderBoxY = metrics.BorderBoxY + _layoutScrollY }; + + /// + /// Gets the box of the element (in page coordinates, see ) as seen in + /// the viewport when the page is scrolled by the given offsets: the page moves, boxes in + /// fixed boxes stay, and sticky boxes (with their content) stick anew. + /// + public ElementLayoutMetrics ToViewport(ElementLayoutMetrics metrics, double scrollX, double scrollY) + { + if (metrics.IsViewportAnchored) + { + return metrics; + } + + var offsetX = (float)-scrollX; + var offsetY = (float)-scrollY; + // the sticky boxes are as laid out, scrolled by _layoutScrollY + var viewport = GetStickyViewport(_context); + viewport = new RenderRect(viewport.X + (float)scrollX, viewport.Y + (float)scrollY - _layoutScrollY, viewport.Width, viewport.Height); + + for (var sticky = metrics.Sticky; sticky is not null && _stickies.TryGetValue(sticky, out var record); sticky = record.Box.EnclosingSticky) + { + var (stickX, stickY) = ComputeStickyOffset(record.Box, record.ContainingBlock, viewport); + offsetX += stickX - record.OffsetX; + offsetY += stickY - record.OffsetY; + } + + return metrics with { BorderBoxX = metrics.BorderBoxX + offsetX, BorderBoxY = metrics.BorderBoxY + offsetY }; + } + + /// + /// Gets the boxes of all elements as seen in the viewport (see ). + /// + public IReadOnlyDictionary ToViewport(double scrollX, double scrollY) + { + if (scrollX == 0d && scrollY == 0d && _layoutScrollY == 0f && _stickies.Count == 0) + { + return _pageMetrics; + } + + var result = new Dictionary(_pageMetrics.Count, ReferenceEqualityComparer.Instance); + + foreach (var (element, metrics) in _pageMetrics) + { + result[element] = ToViewport(metrics, scrollX, scrollY); + } + + return result; + } + + /// + /// Takes the changes of the document into the metrics. Changes are confined to the + /// subtree of the element they are made to (its inline style, a class or attribute no + /// selector with a sibling combinator or :has() refers to, or content changing no + /// match of a :has() around it), and they only change the metrics of that subtree + /// when its nearest box out of the flow (or not rendered) stays out of it, or its nearest + /// in-flow block (or one of the next few around it) stays the same from outside. Anything + /// else needs a full layout. + /// + /// The changes since the metrics were taken. + /// True if the metrics are up to date, false if a full layout is needed. + public bool TryUpdate(IReadOnlyList records) + { + // how many enclosing roots a change may be laid out with, before a full layout is cheaper + const int MaxEscalations = 3; + + if (_document.DefaultView is not { } window) + { + return false; + } + + var roots = new List(); + + foreach (var record in records) + { + if (GetChangedSubtree(record) is not { } changed || FindLayoutRoot(changed) is not { } root) + { + return false; + } + + if (!roots.Any(other => other == root || other.Contains(root))) + { + roots.RemoveAll(root.Contains); + roots.Add(root); + } + } + + var previousPass = s_layoutPass.Value; + s_layoutPass.Value = _pass; + + try + { + var laidOut = new List(); + + foreach (var root in roots) + { + // laid out with an enclosing root already + if (laidOut.Any(other => other.Contains(root))) + { + continue; + } + + var current = root; + + for (var attempt = 0; !TryLayOutAgain(window, current, out var escalates); attempt++) + { + if (!escalates || attempt == MaxEscalations || current.ParentElement is not { } parentElement || + FindLayoutRoot(parentElement) is not { } enclosing) + { + return false; + } + + current = enclosing; + } + + laidOut.Add(current); + UpdateRelationalMatches(current); + } + } + finally + { + s_layoutPass.Value = previousPass; + } + + return true; + } + + private IElement? GetChangedSubtree(IMutationRecord record) + { + // style sheets come and go with style and link elements, which styles everything anew + if (ChangesStyleSheets(record)) + { + return null; + } + + switch (record.Type) + { + case "attributes" when record.Target is IElement element && record.AttributeName is { } name: + return IsConfinedAttributeChange(element, name, record.PreviousValue) ? element : null; + case "childList" when record.Target is IElement parent: + return KeepsRelationalMatches(parent) ? parent : null; + case "characterData" when record.Target.Parent is IElement parent: + return KeepsRelationalMatches(parent) ? parent : null; + default: + return null; + } + } + + private static bool ChangesStyleSheets(IMutationRecord record) + { + return IsSheetOwner(record.Target as IElement) || IsSheetOwner(record.Target.Parent as IElement) || + ContainsSheetOwner(record.Added) || ContainsSheetOwner(record.Removed); + + static bool IsSheetOwner(IElement? element) => element?.LocalName is "style" or "link"; + + static bool ContainsSheetOwner(INodeList? nodes) => + nodes is not null && nodes.OfType().Any(element => IsSheetOwner(element) || element.QuerySelector("style, link") is not null); + } + + private bool IsConfinedAttributeChange(IElement element, string name, string? previousValue) + { + var selectors = GetSiblingSelectors(); + + var value = element.GetAttribute(name); + + // e.g. :not([class*="ltag"] *) is only of interest when it matches one of the values only + if (!selectors.MatchesAlike(name, previousValue, value)) + { + return false; + } + + switch (name) + { + case "style": + return true; + case "class": + var before = (previousValue ?? string.Empty).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + var after = (value ?? string.Empty).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return !before.Except(after).Concat(after.Except(before)).Any(token => selectors.Refers("." + token)); + case "id": + return !selectors.Refers("#" + previousValue) && !selectors.Refers("#" + value); + default: + return !selectors.RefersToAttributeState; + } + } + + private SiblingSelectors GetSiblingSelectors() => _siblingSelectors ??= SiblingSelectors.Create(_document); + + // If the content of the element changed without changing which elements a `:has()` + // matches outside of it: the element and its ancestors, whose `:has()` looks into it. + private bool KeepsRelationalMatches(IElement element) + { + foreach (var (compound, matches) in _relationalMatches) + { + if (matches is null) + { + return false; + } + + for (var current = element; current is not null; current = current.ParentElement) + { + if (TryMatches(current, compound) is not { } isMatch || isMatch != matches.Contains(current)) + { + return false; + } + } + } + + return true; + } + + // the content of a root laid out again may match differently now + private void UpdateRelationalMatches(IElement root) + { + foreach (var (compound, matches) in _relationalMatches) + { + foreach (var element in root.DescendantsAndSelf()) + { + if (TryMatches(element, compound) == true) + { + matches!.Add(element); + } + else + { + matches!.Remove(element); + } + } + } + } + + private static HashSet? TryQuerySelectorAll(IDocument document, string selector) + { + try + { + return new HashSet(document.QuerySelectorAll(selector), ReferenceEqualityComparer.Instance); + } + catch (DomException) + { + return null; + } + } + + private static bool? TryMatches(IElement element, string selector) + { + try + { + return element.Matches(selector); + } + catch (DomException) + { + return null; + } + } + + // the nearest element whose layout started in the last full layout (out of the flow, not + // rendered, or a block of a block flow) below the body, or a not rendered child of a block + private IElement? FindLayoutRoot(IElement element) + { + // laying out the body again is no cheaper than a full layout + for (var current = element; current is not null && current != _document.Body; current = current.ParentElement) + { + if (_entries.ContainsKey(current)) + { + return current; + } + + if (current.ParentElement is { } parent && _containers.TryGetValue(parent, out var container) && + container.Node.Children.OfType().FirstOrDefault(child => child.Ref == current) is { } node && + !GeneratesBoxes(node)) + { + // as laid out from the inline content of the block, as most of them are + _entries[current] = new LayoutEntry(node, container.ContentX, container.ContentY, container.ContentWidth, container.ContentY, false, + container.TextStyle, container.MaxY, container.ContentHeight, container.PositionedContainingX, container.PositionedContainingY, + container.PositionedContainingWidth, container.PositionedContainingHeight, + float.IsNaN(container.PositionedContainingHeight) ? parent : null, HasBox: false, HasStaticPosition: false); + return current; + } + } + + return null; + } + + // Lays out a root again on its own. An in-flow block has to come out as it was (its box, + // and what it leaves for the boxes after it), else (or when a box enters the flow) the + // enclosing root is to be laid out instead (`escalates`). + private bool TryLayOutAgain(IWindow window, IElement root, out bool escalates) + { + var entry = _entries[root]; + escalates = false; + + // a subtree of its own shadow tree, or in a vertical writing mode, is not taken apart + if (entry.Node.Parent is not { } parent || root.GetRoot() is not IDocument || + entry.TextStyle.WritingMode != WritingModeKind.HorizontalTb || entry.TextStyle.FlowMode != WritingModeKind.HorizontalTb) + { + return false; + } + + // an in-flow block with nothing reaching out of its subtree (or into it from above) + var result = entry.Result; + ElementLayoutMetrics laidOutBefore = default; + + if (entry.IsInFlow && (result is not { LeftNoFloats: true } || !_metrics.TryGetValue(root, out laidOutBefore) || + GetSiblingSelectors().HasFirstFormatted || HasStickyAncestor(root) || ReachesOutOfItsSubtree(entry.Node))) + { + escalates = true; + return false; + } + + // the margin the parent took as its own (see TryGetFirstCollapsibleChildTopMargin) + var absorbedTopMargin = entry.IsInFlow && entry.SuppressNextBlockTopMargin ? GetCollapsedTopMargin(entry.Node, entry.ContainingWidth) : 0f; + var intrinsicWidths = entry.IsSizedByContent ? ComputeOuterIntrinsicWidths(entry.Node, entry.TextStyle, entry.ContainingWidth, _context) : default; + + // what the display list shows of it, before it is forgotten + var hadBox = _rendered.TryGetValue(root, out var rendered) ? rendered.Before : entry.HasBox; + (RenderCommand First, int Count)? before = _ranges.TryGetValue(root, out var range) ? range : null; + + Forget(entry.Node); + PrepareElementsForRendering(root.DescendantsAndSelf()); + + var node = window.Render(root, parent, _renderDevice); + BuildAnonymousTableBoxes(node); + CollectSpecifiedStyles(node, _context); + ResolveWritingModeSystems(node, WritingModeKind.HorizontalTb, _context); + ProcessCounters(node, _context); + + var styleMap = GetOrCreateStyleMap(_context, root, node.ComputedStyle); + + if (entry.IsInFlow) + { + // the block has to stay one of the flow + if (!GeneratesBoxes(node) || IsOutOfFlowPositioned(styleMap) || IsFloated(styleMap) || IsStickyPositioned(styleMap) || + ClassifyFlowChild(node, _context) != FlowChildKind.Block || ReachesOutOfItsSubtree(node)) + { + escalates = true; + return false; + } + } + else if (GeneratesBoxes(node) && !IsOutOfFlowPositioned(styleMap)) + { + // a box entering the flow moves the boxes after it + escalates = true; + return false; + } + else if (GeneratesBoxes(node) && !entry.HasStaticPosition && !IsPlacedByInsets(styleMap)) + { + // without a static position to start from, the box has to be placed by its insets + return false; + } + + var positionedContainingHeight = entry.PositionedContainingHeight; + var textIndentConsumed = entry.TextIndentConsumed; + + // a box waiting for the height of its parent is laid out once that is known (see LayoutElement) + if (entry.DeferContainer is { } container && DependsOnContainingBlockHeight(styleMap)) + { + if (!_containers.TryGetValue(container, out var containerEntry) || float.IsNaN(containerEntry.PaddingBoxHeight)) + { + return false; + } + + positionedContainingHeight = containerEntry.PaddingBoxHeight; + textIndentConsumed = true; + } + + var capture = new LayoutCapture { Entries = _entries, Containers = _containers, Links = _links, Stickies = _stickies, Ranges = _ranges, Layers = _layers }; + var displayList = new DisplayList { Fonts = _context.Fonts }; + var previous = s_layoutCapture.Value; + s_layoutCapture.Value = capture; + + var cursorY = entry.CursorY; + var previousBlockMarginBottom = entry.PreviousBlockMarginBottom; + var suppressNextBlockTopMargin = entry.SuppressNextBlockTopMargin; + var floats = new FloatArea(); + + try + { + LayoutNode( + node, + entry.ContainingX, + entry.ContainingY, + entry.ContainingWidth, + ref cursorY, + ref previousBlockMarginBottom, + ref suppressNextBlockTopMargin, + floats, + ref textIndentConsumed, + entry.TextStyle, + _context, + displayList, + entry.MaxY, + containingHeight: entry.ContainingHeight, + positionedContainingX: entry.PositionedContainingX, + positionedContainingY: entry.PositionedContainingY, + positionedContainingWidth: entry.PositionedContainingWidth, + positionedContainingHeight: positionedContainingHeight); + + // the scroll containers around it move its metrics as they did in the full layout + for (var ancestor = root.ParentElement; ancestor is not null; ancestor = ancestor.ParentElement) + { + if (_context.StyleMapCache.TryGetValue(ancestor, out var ancestorStyleMap) && ShouldClipOverflow(ancestorStyleMap) && + GetContentScrollOffset(ancestor) is var scroll && scroll != default) + { + MoveScrolledContent(capture, 0, ancestor, scroll); + } + } + } + finally + { + s_layoutCapture.Value = previous; + } + + // the boxes after an in-flow block are where they were if it is the same from outside + if (entry.IsInFlow && (cursorY != result!.CursorY || previousBlockMarginBottom != result.PreviousBlockMarginBottom || + suppressNextBlockTopMargin != result.SuppressNextBlockTopMargin || textIndentConsumed != result.TextIndentConsumed || !floats.IsEmpty || + !capture.TryGet(root, out var laidOut) || !HasSameBox(laidOut, laidOutBefore) || + (entry.IsSizedByContent && ComputeOuterIntrinsicWidths(node, entry.TextStyle, entry.ContainingWidth, _context) != intrinsicWidths) || + (entry.SuppressNextBlockTopMargin && GetCollapsedTopMargin(node, entry.ContainingWidth) != absorbedTopMargin))) + { + escalates = true; + return false; + } + + foreach (var (element, metrics) in AddHitTestLayers(capture.Snapshot(), _context)) + { + _metrics[element] = metrics; + _pageMetrics[element] = ToPage(metrics); + } + + _rendered[root] = (hadBox, GeneratesBoxes(node)); + _relayouts.Add((root, before, displayList.Commands.ToList())); + + return true; + } + + private static bool HasSameBox(ElementLayoutMetrics first, ElementLayoutMetrics second) => + first with { Layer = default, Sticky = null } == second with { Layer = default, Sticky = null }; + + // the top margin of a block with those collapsing through it from its first children (see LayoutElement) + private float GetCollapsedTopMargin(ElementRenderNode node, float containingWidth) + { + var styleMap = GetOrCreateStyleMap(_context, node.Ref, node.ComputedStyle); + var box = ResolveBoxStyle(styleMap, node.Ref); + var marginTop = ParseLength(styleMap, "margin-top", containingWidth, box.Margin.Top, allowAuto: false); + var paddingTop = ParseLength(styleMap, "padding-top", containingWidth, box.Padding.Top, allowAuto: false); + + return box.BorderWidth.Top <= 0f && paddingTop <= 0f && !EstablishesBlockFormattingContext(styleMap, false, false, false, false) && + TryGetFirstCollapsibleChildTopMargin(node, containingWidth, _context, out var firstChildTopMargin) + ? CollapseMargins(marginTop, firstChildTopMargin) + : marginTop; + } + + // sticky boxes move what they contain (and its metrics) after its layout + private bool HasStickyAncestor(IElement element) + { + for (var ancestor = element.ParentElement; ancestor is not null; ancestor = ancestor.ParentElement) + { + if (_context.StyleMapCache.TryGetValue(ancestor, out var styleMap) && IsStickyPositioned(styleMap)) + { + return true; + } + } + + return false; + } + + // What a subtree lays out beyond itself: sticky boxes (stuck by an ancestor), content with + // a negative z-index (painted below the in-flow content of its stacking context), and + // counters (counted through the whole document). + private bool ReachesOutOfItsSubtree(IRenderNode node) + { + if (node is ElementRenderNode element) + { + var styleMap = GetOrCreateStyleMap(_context, element.Ref, element.ComputedStyle); + + if (IsStickyPositioned(styleMap) || (HasExplicitZIndex(styleMap) && ParseZIndex(styleMap) < 0) || + IsCounting("counter-increment") || IsCounting("counter-reset") || IsCounting("counter-set") || + (element.Ref is IPseudoElement && element.ComputedStyle.GetPropertyValue("content")?.Contains("counter", StringComparison.OrdinalIgnoreCase) == true)) + { + return true; + } + + bool IsCounting(string property) => styleMap.TryGetValue(property, out var value) && !string.IsNullOrWhiteSpace(value) && + !string.Equals(value.Trim(), "none", StringComparison.OrdinalIgnoreCase); + } + + return node.Children.Any(ReachesOutOfItsSubtree); + } + + private static bool IsPlacedByInsets(Dictionary styleMap) + { + return string.Equals(GetPosition(styleMap), "absolute", StringComparison.OrdinalIgnoreCase) && + (IsSet("top") || IsSet("bottom")) && (IsSet("left") || IsSet("right")); + + bool IsSet(string inset) => styleMap.TryGetValue(inset, out var value) && !string.IsNullOrWhiteSpace(value) && + !string.Equals(value.Trim(), "auto", StringComparison.OrdinalIgnoreCase); + } + + // Drops what is known about the elements of the old subtree (pseudo-elements included, + // which belong to no DOM subtree); its layout starts from the entry of its root again. + private void Forget(ElementRenderNode root) + { + var elements = new HashSet(ReferenceEqualityComparer.Instance); + Collect(root, elements); + + foreach (var element in elements) + { + _metrics.Remove(element); + _pageMetrics.Remove(element); + _context.StyleMapCache.Remove(element); + _context.SpecifiedStyles.Remove(element); + _context.TableStructures.Remove(element); + _context.BoxSystems.Remove(element); + _context.ContentSystems.Remove(element); + _containers.Remove(element); + _stickies.Remove(element); + _ranges.Remove(element); + _pass.Styles.Remove(element); + _pass.ComputedStyles.Remove(element); + _pass.PercentageHeights.Remove(element); + + // where the root starts and paints is known from outside the subtree + if (element != root.Ref) + { + _entries.Remove(element); + _layers.Remove(element); + } + } + + // whether a subtree has percentage heights is known for the ancestors as well + for (var ancestor = root.Ref.ParentElement; ancestor is not null; ancestor = ancestor.ParentElement) + { + _pass.PercentageHeights.Remove(ancestor); + } + + RemoveKeys(_context.MeasuredHeights, key => elements.Contains(key.Element)); + RemoveKeys(_context.GridItemHeights, key => elements.Contains(key.Element)); + + static void Collect(IRenderNode node, HashSet elements) + { + if (node is ElementRenderNode element) + { + elements.Add(element.Ref); + } + + foreach (var child in node.Children) + { + Collect(child, elements); + } + } + + static void RemoveKeys(Dictionary dictionary, Func predicate) + where TKey : notnull + { + foreach (var key in dictionary.Keys.Where(predicate).ToList()) + { + dictionary.Remove(key); + } + } + } + } + + /// + /// What the selectors relating an element to others than its descendants (with a sibling + /// combinator or :has()) refer to: changing anything else of an element only changes + /// the style of its own subtree. + /// + private sealed class SiblingSelectors + { + private readonly HashSet _tokens = new(StringComparer.Ordinal); + private readonly Dictionary> _attributes = new(StringComparer.OrdinalIgnoreCase); + + /// Gets if a selector with :has() exists, which may depend on anything. + public bool HasRelational { get; private set; } + + /// Gets the compound selectors with :has() (without pseudo-elements), whose matches a change may flip. + public List RelationalCompounds { get; } = []; + + /// Gets if a selector with ::first-line or ::first-letter exists, which styles the first line of a descendant. + public bool HasFirstFormatted { get; private set; } + + /// Gets if such a selector has a pseudo-class depending on an attribute (e.g. :checked). + public bool RefersToAttributeState { get; private set; } + + public bool Refers(string token) => _tokens.Contains(token); + + /// + /// Gets if the attribute selectors on the attribute match both values or neither, with + /// and without case (HTML compares the values of some attributes, e.g. type, without). + /// + public bool MatchesAlike(string name, string? before, string? after) => + !_attributes.TryGetValue(name, out var selectors) || selectors.All(selector => + selector.Matches(before, selector.IgnoresCase) == selector.Matches(after, selector.IgnoresCase) && + selector.Matches(before, ignoreCase: true) == selector.Matches(after, ignoreCase: true)); + + // the compounds of a selector list, split at the combinators outside of any parentheses, + // brackets or strings; a pseudo-element is left out (it is matched by its originating element) + private static IEnumerable SplitCompounds(string selector) + { + var depth = 0; + var quote = '\0'; + var start = 0; + + for (var index = 0; index <= selector.Length; index++) + { + var c = index < selector.Length ? selector[index] : ','; + + if (quote != '\0') + { + quote = c == quote ? '\0' : quote; + } + else if (c is '"' or '\'') + { + quote = c; + } + else if (c is '(' or '[') + { + depth++; + } + else if (c is ')' or ']') + { + depth--; + } + else if (depth == 0 && (c is ',' or '>' or '+' or '~' || char.IsWhiteSpace(c))) + { + if (Compound(selector[start..index]) is { Length: > 0 } compound) + { + yield return compound; + } + + start = index + 1; + } + } + + static string Compound(string text) + { + var pseudoElement = text.IndexOf("::", StringComparison.Ordinal); + var compound = (pseudoElement >= 0 ? text[..pseudoElement] : text).Trim(); + return compound.Length == 0 && pseudoElement >= 0 ? "*" : compound; + } + } + + public static SiblingSelectors Create(IDocument document) + { + var selectors = new SiblingSelectors(); + + foreach (var sheet in document.StyleSheets.OfType()) + { + selectors.Add(sheet.Rules); + } + + return selectors; + } + + private void Add(ICssRuleList rules) + { + foreach (var rule in rules) + { + switch (rule) + { + case ICssStyleRule style: + Add(style.SelectorText); + Add(style.Rules); + break; + case ICssGroupingRule group: + Add(group.Rules); + break; + case ICssImportRule { Sheet: { } sheet }: + Add(sheet.Rules); + break; + } + } + } + + private void Add(string? selector) + { + if (string.IsNullOrEmpty(selector)) + { + return; + } + + var hasRelational = selector.Contains(":has(", StringComparison.OrdinalIgnoreCase); + HasFirstFormatted |= selector.Contains(":first-line", StringComparison.OrdinalIgnoreCase) || selector.Contains(":first-letter", StringComparison.OrdinalIgnoreCase); + + // a '+' in an argument (e.g. :nth-child(2n+1)) is taken for a combinator, which only costs a full layout + if (!hasRelational && !selector.Contains('+') && !selector.Contains('~')) + { + return; + } + + HasRelational |= hasRelational; + + if (hasRelational) + { + foreach (var compound in SplitCompounds(selector).Where(compound => compound.Contains(":has(", StringComparison.OrdinalIgnoreCase))) + { + if (!RelationalCompounds.Contains(compound)) + { + RelationalCompounds.Add(compound); + } + } + } + + foreach (Match match in s_attributeSelectors.Matches(selector)) + { + var name = match.Groups[1].Value; + var expected = match.Groups[3].Success ? match.Groups[3].Value : match.Groups[4].Success ? match.Groups[4].Value : match.Groups[5].Value; + + if (!_attributes.TryGetValue(name, out var list)) + { + _attributes[name] = list = []; + } + + list.Add(new AttributeSelector(match.Groups[2].Value, expected, match.Groups[6].Value is "i" or "I")); + } + + foreach (Match match in s_selectorTokens.Matches(selector)) + { + var token = match.Value.Replace("\\", string.Empty); + _tokens.Add(token); + RefersToAttributeState |= token[0] == ':' && s_attributeStatePseudoClasses.Contains(token); + } + } + } + + /// An attribute selector (e.g. [class*="ltag"]), evaluated against a value of the attribute. + private sealed record AttributeSelector(string Operator, string Value, bool IgnoresCase) + { + public bool Matches(string? actual, bool ignoreCase) + { + if (actual is null) + { + return false; + } + + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + return Operator switch + { + "" => true, + "=" => string.Equals(actual, Value, comparison), + "~=" => Value.Length > 0 && !Value.Any(char.IsWhiteSpace) && + actual.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Any(word => string.Equals(word, Value, comparison)), + "|=" => string.Equals(actual, Value, comparison) || actual.StartsWith(Value + "-", comparison), + "^=" => Value.Length > 0 && actual.StartsWith(Value, comparison), + "$=" => Value.Length > 0 && actual.EndsWith(Value, comparison), + "*=" => Value.Length > 0 && actual.Contains(Value, comparison), + _ => true, + }; + } + } +} diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs index f514fea..d3813de 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs @@ -170,12 +170,36 @@ void FlushRun(ref float cursorY, ref float previousBlockMarginBottom, ref bool s if (cursorY > maxY && !ContainsBottomStickyBox(children.Skip(index + 1), context)) { paintOrder.Apply(displayList); + RecordPaintLayer(paintOrder, children, displayList); return; } } FlushRun(ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed); paintOrder.Apply(displayList); + RecordPaintLayer(paintOrder, children, displayList); + } + + /// + /// Notes where the not rendered children of a flow would paint once shown: among the + /// children moved to the end (by z-index, then document order), else after the flow. + /// + private static void RecordPaintLayer(PaintOrder paintOrder, IEnumerable children, DisplayList displayList) + { + if (s_layoutCapture.Value is not { Layers: { } layers } || displayList.Commands.Count == 0) + { + return; + } + + PaintLayer? layer = null; + + foreach (var child in children) + { + if (child is ElementRenderNode element && !GeneratesBoxes(element)) + { + layers[element.Ref] = layer ??= new PaintLayer(paintOrder.Applied, displayList.Commands[^1]); + } + } } private enum FlowChildKind @@ -2104,6 +2128,36 @@ private static void RecordTextBaseline(float baselineY) } } + /// + /// Keeps the baselines noted so far while an out-of-flow box is laid out: its lines are no + /// part of the lines its container aligns by (an inline-block's baseline is that of its last + /// in-flow line box, CSS 2.1 §10.8.1). + /// + private readonly struct BaselineScope : IDisposable + { + private readonly LayoutCapture? _capture; + private readonly float? _firstBaselineY; + private readonly float? _lastBaselineY; + + private BaselineScope(LayoutCapture capture) + { + _capture = capture; + _firstBaselineY = capture.FirstBaselineY; + _lastBaselineY = capture.LastBaselineY; + } + + public static BaselineScope Begin() => s_layoutCapture.Value is { } capture ? new BaselineScope(capture) : default; + + public void Dispose() + { + if (_capture is not null) + { + _capture.FirstBaselineY = _firstBaselineY; + _capture.LastBaselineY = _lastBaselineY; + } + } + } + /// /// The border-box width of a box whose width is auto but which must not simply fill its /// container (inline-blocks, floats, absolutely positioned boxes): its authored width when it diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs b/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs index d00bcec..8ca803d 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs @@ -24,10 +24,19 @@ public sealed partial class HtmlRenderer [ThreadStatic] private static StickyProbe? s_stickyProbe; + // what the boxes laid out now move with: fixed boxes (any depth), the innermost sticky box + [ThreadStatic] + private static int s_viewportAnchorDepth; + + [ThreadStatic] + private static IElement? s_stickyElement; + /// /// A sticky box waiting for its container: the display list it was emitted into, its first /// command (found again by identity, since later insertions shift indices) and command count, - /// and its natural border box. + /// and its natural border box. The metrics recorded while it was laid out (its own and those + /// of its content) are of , and + /// is the sticky box it is in, if any. /// private sealed record StickyBox( DisplayList DisplayList, @@ -35,7 +44,16 @@ private sealed record StickyBox( int Count, IElement Element, Dictionary Style, - ElementLayoutMetrics Metrics); + ElementLayoutMetrics Metrics, + LayoutCapture? Capture = null, + Range Records = default, + IElement? EnclosingSticky = null); + + /// + /// A sticky box as resolved against the viewport at the scroll position the layout was made + /// for: with its containing block it sticks anew for another one (see LayoutSnapshot.ToViewport). + /// + private sealed record StickyRecord(StickyBox Box, RenderRect ContainingBlock, float OffsetX, float OffsetY); /// /// A sticky box inside a writing-mode system, whose offset waits until the system is placed: @@ -120,15 +138,22 @@ private static void LayoutStickyAwareElement( var previousProbe = s_stickyProbe; var probe = isSticky ? new StickyProbe(node.Ref) : null; var isViewportAnchored = string.Equals(GetPosition(styleMap), "fixed", StringComparison.Ordinal); + var enclosingSticky = s_stickyElement; + var capture = s_layoutCapture.Value; + var recordStart = capture?.RecordOrder.Count ?? 0; if (probe is not null) { s_stickyProbe = probe; + s_stickyElement = node.Ref; } if (isViewportAnchored) { displayList.Add(new PushViewportAnchorCommand()); + // a fixed box does not move with the sticky boxes around it + s_viewportAnchorDepth++; + s_stickyElement = null; } context.StickyScopes.Push([]); @@ -141,16 +166,40 @@ private static void LayoutStickyAwareElement( { context.StickyScopes.Pop(); s_stickyProbe = previousProbe; + s_stickyElement = enclosingSticky; if (isViewportAnchored) { displayList.Add(new PopViewportAnchorCommand()); + s_viewportAnchorDepth--; + } + } + + // what a block of a block flow leaves for the boxes after it + if (capture is { Entries: { } entries } && ReferenceEquals(capture, s_layoutCapture.Value) && + entries.TryGetValue(node.Ref, out var entry) && entry.IsInFlow && ReferenceEquals(entry.Node, node)) + { + entries[node.Ref] = entry with { Result = new FlowResult(cursorY, previousBlockMarginBottom, suppressNextBlockTopMargin, textIndentConsumed, floats.IsEmpty) }; + } + + // where the box that may be laid out on its own is in the display list + if (capture is { Ranges: { } ranges } && ReferenceEquals(capture, s_layoutCapture.Value) && capture.Entries!.ContainsKey(node.Ref)) + { + + if (displayList.Commands.Count > start) + { + ranges[node.Ref] = (displayList.Commands[start], displayList.Commands.Count - start); + } + else + { + ranges.Remove(node.Ref); } } if (probe?.Metrics is { } metrics && displayList.Commands.Count > start && context.StickyScopes.TryPeek(out var scope)) { - scope.Add(new StickyBox(displayList, displayList.Commands[start], displayList.Commands.Count - start, node.Ref, styleMap, metrics)); + var records = ReferenceEquals(capture, s_layoutCapture.Value) && capture is not null ? recordStart..capture.RecordOrder.Count : default; + scope.Add(new StickyBox(displayList, displayList.Commands[start], displayList.Commands.Count - start, node.Ref, styleMap, metrics, capture, records, enclosingSticky)); } } @@ -202,6 +251,11 @@ private static void ResolveStickyBoxes(DisplayList displayList, List var (offsetX, offsetY) = ComputeStickyOffset(sticky, containingBlock, GetStickyViewport(context)); + if (s_layoutCapture.Value is { Stickies: { } stickies }) + { + stickies[sticky.Element] = new StickyRecord(sticky, containingBlock, offsetX, offsetY); + } + if (offsetX == 0f && offsetY == 0f) { continue; @@ -276,6 +330,24 @@ private static RenderRect GetStickyViewport(LayoutContext context) => private static void RecordStickyMetrics(StickyBox sticky, float offsetX, float offsetY) { + // the box moves with its content + if (sticky.Capture is { } capture && ReferenceEquals(capture, s_layoutCapture.Value) && sticky.Records.End.Value > sticky.Records.Start.Value) + { + var moved = new HashSet(ReferenceEqualityComparer.Instance); + + for (var index = sticky.Records.Start.Value; index < sticky.Records.End.Value; index++) + { + var element = capture.RecordOrder[index]; + + if (moved.Add(element) && capture.TryGet(element, out var recorded) && !recorded.IsViewportAnchored) + { + capture.Replace(element, recorded with { BorderBoxX = recorded.BorderBoxX + offsetX, BorderBoxY = recorded.BorderBoxY + offsetY }); + } + } + + return; + } + var metrics = sticky.Metrics; RecordLayoutMetrics( sticky.Element, diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 449467c..5de5b6a 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -70,6 +70,10 @@ private sealed record AuthoredRule(string[] Selectors, string Value); /// private static readonly ConditionalWeakTable s_generatedContentImages = new(); private static readonly AsyncLocal s_layoutCapture = new(); + + // the boxes placed by their bottom whose auto height is being measured (see LayoutElement) + [ThreadStatic] + private static HashSet? s_measuringPositioned; private static readonly AsyncLocal s_layoutPass = new(); private static readonly ITextMeasurer s_defaultTextMeasurer = new SkiaTextMeasurer(); @@ -84,6 +88,11 @@ private sealed class DocumentImageCache private sealed record CachedImageResource(byte[] Bytes, string MimeType, int NaturalWidth, int NaturalHeight); + /// + /// The box is in page coordinates (unscrolled), except for one inside a fixed box + /// (), which is in the viewport; + /// is the innermost sticky box it moves with (see LayoutSnapshot.ToViewport). + /// internal readonly record struct ElementLayoutMetrics( float BorderBoxX, float BorderBoxY, @@ -97,7 +106,9 @@ internal readonly record struct ElementLayoutMetrics( float PaddingRight, float PaddingTop, float PaddingBottom, - HitTestLayer Layer = default); + HitTestLayer Layer = default, + bool IsViewportAnchored = false, + IElement? Sticky = null); /// /// What hit testing needs to know about an element beyond its box: where it paints relative @@ -163,11 +174,37 @@ private sealed class LayoutCapture /// The baseline of the first line of text laid out while this capture was active. public float? FirstBaselineY { get; set; } + /// Where the layouts of out-of-flow boxes started, for laying them out again on their own (see ); none for trial layouts. + public Dictionary? Entries { get; init; } + + /// Where the not rendered children of a flow would paint, for showing them in a frame (see ). + public Dictionary? Layers { get; init; } + + /// The commands of the boxes with , for replacing them in a frame (see ). + public Dictionary? Ranges { get; init; } + + /// What blocks gave their children to start their layouts from (see ); none for trial layouts. + public Dictionary? Containers { get; init; } + + /// The flex and grid items passing on the in-flow entries of their container to their content (see ). + public HashSet? Links { get; init; } + + /// The elements in the order their metrics were recorded (see RecordStickyMetrics). + public List RecordOrder { get; } = []; + + /// The sticky boxes resolved against the viewport, for scrolling them later (see ); none for trial layouts. + public Dictionary? Stickies { get; init; } + public void Record(IElement element, ElementLayoutMetrics metrics) { _metricsByElement[element] = metrics; + RecordOrder.Add(element); } + public bool TryGet(IElement element, out ElementLayoutMetrics metrics) => _metricsByElement.TryGetValue(element, out metrics); + + public void Replace(IElement element, ElementLayoutMetrics metrics) => _metricsByElement[element] = metrics; + public IReadOnlyDictionary Snapshot() { return _metricsByElement; @@ -179,21 +216,7 @@ internal static IReadOnlyDictionary CaptureLayou ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(renderDevice); - var context = CreateLayoutContext(document, renderDevice, s_defaultTextMeasurer); - var viewport = new RenderViewport(context.Width, context.Height); - var capture = new LayoutCapture(); - var previous = s_layoutCapture.Value; - s_layoutCapture.Value = capture; - - try - { - _ = BuildDisplayList(document, viewport, context, renderDevice, measureFullExtent: true); - return AddHitTestLayers(capture.Snapshot(), context); - } - finally - { - s_layoutCapture.Value = previous; - } + return LayoutSnapshot.Capture(document, renderDevice).Metrics; } private static float ZeroIfNaN(float value) => float.IsNaN(value) ? 0f : value; @@ -259,18 +282,7 @@ private static void RecordLayoutMetrics( float paddingTop, float paddingBottom) { - if (s_stickyProbe is { } probe && ReferenceEquals(probe.Element, element)) - { - probe.Metrics = new ElementLayoutMetrics(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, borderLeft, borderRight, borderTop, borderBottom, paddingLeft, paddingRight, paddingTop, paddingBottom); - } - - var capture = s_layoutCapture.Value; - if (capture is null) - { - return; - } - - capture.Record(element, new ElementLayoutMetrics( + var metrics = new ElementLayoutMetrics( borderBoxX, borderBoxY, borderBoxWidth, @@ -282,7 +294,16 @@ private static void RecordLayoutMetrics( paddingLeft, paddingRight, paddingTop, - paddingBottom)); + paddingBottom, + IsViewportAnchored: s_viewportAnchorDepth > 0, + Sticky: s_stickyElement); + + if (s_stickyProbe is { } probe && ReferenceEquals(probe.Element, element)) + { + probe.Metrics = metrics; + } + + s_layoutCapture.Value?.Record(element, metrics); } /// @@ -515,6 +536,45 @@ public DisplayList BuildDisplayList(IDocument document, IRenderDevice renderDevi /// newly-set scroll position depends on exactly that), so metrics capture always lays out at /// scroll offset 0 regardless of whatever is currently scrolled into view for painting. /// + /// + /// The scroll position of a scroll container, which moves its content within its padding + /// box; the page's is the viewport's (see ). + /// + private static (float X, float Y) GetContentScrollOffset(IElement element) + { + if (element.Owner is not { } document || ReferenceEquals(element, document.DocumentElement) || + !document.Context.TryGetDomHarness(out var harness) || harness is null) + { + return default; + } + + return ((float)harness.GetScrollLeft(element, double.MaxValue), (float)harness.GetScrollTop(element, double.MaxValue)); + } + + /// + /// Moves the metrics the content of a scroll container recorded by its scroll position, as + /// its commands are; boxes in fixed boxes stay. + /// + private static void MoveScrolledContent(LayoutCapture? capture, int recordStart, IElement container, (float X, float Y) scroll) + { + if (capture is null || !ReferenceEquals(capture, s_layoutCapture.Value)) + { + return; + } + + var moved = new HashSet(ReferenceEqualityComparer.Instance) { container }; + + for (var index = recordStart; index < capture.RecordOrder.Count; index++) + { + var element = capture.RecordOrder[index]; + + if (moved.Add(element) && capture.TryGet(element, out var recorded) && !recorded.IsViewportAnchored) + { + capture.Replace(element, recorded with { BorderBoxX = recorded.BorderBoxX - scroll.X, BorderBoxY = recorded.BorderBoxY - scroll.Y }); + } + } + } + private static float ResolveRootScrollOffsetY(IDocument document) { var scrollingElement = document.DocumentElement; @@ -531,10 +591,10 @@ private static float ResolveRootScrollOffsetY(IDocument document) return (float)harness.GetScrollTop(scrollingElement, double.MaxValue); } - private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice, float scrollOffsetY = 0f, bool measureFullExtent = false, float surfaceExtraHeight = 0f) + private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice, float scrollOffsetY = 0f, bool measureFullExtent = false, float surfaceExtraHeight = 0f, LayoutPass? pass = null) { var previous = s_layoutPass.Value; - var pass = new LayoutPass(); + pass ??= new LayoutPass(); s_layoutPass.Value = pass; // the render's style collection serves the pass as well, instead of gathering and @@ -847,11 +907,21 @@ private static void LayoutElement( var computedStyle = node.ComputedStyle; var styleMap = GetOrCreateStyleMap(context, node.Ref, node.ComputedStyle); + if (s_layoutCapture.Value is { Entries: not null } capture) + { + var isPlain = !isFlexItem && !isAtomicInline && !mainAxisMarginsPlaced && contentOffsetY == 0f && + float.IsNaN(forcedBorderBoxWidth) && float.IsNaN(forcedBorderBoxHeight); + RecordLayoutEntry(capture, node, styleMap, containingX, containingY, containingWidth, cursorY, previousBlockMarginBottom, suppressNextBlockTopMargin, floats, + textIndentConsumed, inheritedTextStyle, context, maxY, isPlain, + containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight); + } + if (!GeneratesBoxes(node)) { return; } + using var baselines = IsOutOfFlowPositioned(styleMap) ? BaselineScope.Begin() : default; var tagName = element.LocalName; if (string.Equals(tagName, "br", StringComparison.OrdinalIgnoreCase)) @@ -1191,8 +1261,8 @@ private static void LayoutElement( : flowContainingWidth; var leftOffset = ParseLength(styleMap, "left", flowContainingWidth, 0f, allowAuto: true); var topOffset = ParseLength(styleMap, "top", offsetHeightReference, 0f, allowAuto: true); - var rightOffset = ParseLength(styleMap, "right", flowContainingWidth, float.NaN, allowAuto: true); - var bottomOffset = ParseLength(styleMap, "bottom", float.IsNaN(positionedContainingHeight) ? flowContainingWidth : positionedContainingHeight, float.NaN, allowAuto: true); + var rightOffset = ParseLength(styleMap, "right", isFixed ? context.Width : flowContainingWidth, float.NaN, allowAuto: true); + var bottomOffset = ParseLength(styleMap, "bottom", isFixed ? context.Height : float.IsNaN(positionedContainingHeight) ? flowContainingWidth : positionedContainingHeight, float.NaN, allowAuto: true); if (float.IsNaN(leftOffset)) { @@ -1291,9 +1361,40 @@ private static void LayoutElement( borderBoxX = absoluteContainingX + absoluteContainingWidth - rightOffset - positionedMargins.Right - positionedBorderBoxWidth; } - if (isAbsolute && !styleMap.ContainsKey("top") && !float.IsNaN(bottomOffset) && !float.IsNaN(positionedContainingHeight) && !float.IsNaN(positionedBorderBoxHeight)) + // a box placed by its bottom needs its height first, which an auto one only has once laid out + var placementHeight = positionedBorderBoxHeight; + + if ((isAbsolute || isFixed) && float.IsNaN(placementHeight) && !styleMap.ContainsKey("top") && !float.IsNaN(bottomOffset) && + (isFixed || !float.IsNaN(positionedContainingHeight)) && (s_measuringPositioned ??= new(ReferenceEqualityComparer.Instance)).Add(element)) + { + // its own scope, so the trial is not held back for the height of the container + context.PositionedScopes.Push(new PositionedScope(node)); + + try + { + placementHeight = TrialLayout(node, flowContainingWidth, inheritedTextStyle, context, positionedBorderBoxWidth, isAtomicInline: false).Metrics?.BorderBoxHeight ?? float.NaN; + } + finally + { + context.PositionedScopes.Pop(); + s_measuringPositioned.Remove(element); + } + } + + if (isAbsolute && !styleMap.ContainsKey("top") && !float.IsNaN(bottomOffset) && !float.IsNaN(positionedContainingHeight) && !float.IsNaN(placementHeight)) + { + borderBoxY = absoluteContainingY + positionedContainingHeight - bottomOffset - positionedMargins.Bottom - placementHeight; + } + + // a fixed box is placed in the viewport (e.g. a button at the bottom right) + if (isFixed && !styleMap.ContainsKey("left") && !float.IsNaN(rightOffset)) { - borderBoxY = absoluteContainingY + positionedContainingHeight - bottomOffset - positionedMargins.Bottom - positionedBorderBoxHeight; + borderBoxX = context.Width - context.Padding - rightOffset - positionedMargins.Right - positionedBorderBoxWidth; + } + + if (isFixed && !styleMap.ContainsKey("top") && !float.IsNaN(bottomOffset) && !float.IsNaN(placementHeight)) + { + borderBoxY = context.Height - context.Padding - bottomOffset - positionedMargins.Bottom - placementHeight; } contentX = borderBoxX + borderLeft + paddingLeft; @@ -1311,6 +1412,9 @@ private static void LayoutElement( // and spliced in before this index once the box's final size is known, rather than simply // appended (which would paint them on top of - and hide - the children). var boxPaintInsertIndex = displayList.Commands.Count; + // what the children record, which moves with the scroll position of a scroll container + var scrolledCapture = s_layoutCapture.Value; + var scrolledRecordStart = scrolledCapture?.RecordOrder.Count ?? 0; List? deferredPositionedBoxes = null; if (string.Equals(display, "list-item", StringComparison.OrdinalIgnoreCase)) @@ -1521,6 +1625,13 @@ formControlKind is FormControlKind.Select or FormControlKind.Button || else { var flowStart = displayList.Commands.Count; + + if (s_layoutCapture.Value is { Containers: { } containers }) + { + containers[element] = new ContainerEntry(node, contentX, contentY + contentOffsetY, contentWidth, currentTextStyle, maxY, resolvedContentHeight, + childPositionedContainingX, childPositionedContainingY, childPositionedContainingWidth, childPositionedContainingHeight, IsFlow: true); + } + context.PositionedScopes.Push(new PositionedScope(node)); LayoutFlowChildren( @@ -1585,6 +1696,12 @@ formControlKind is FormControlKind.Select or FormControlKind.Button || var borderBoxWidth = borderLeft + paddingLeft + contentWidth + paddingRight + borderRight; var borderBoxHeight = borderTop + paddingTop + contentHeight + paddingBottom + borderBottom; + // the height its held back boxes are laid out with (see below) + if (s_layoutCapture.Value is { Containers: { } heldBackContainers } && heldBackContainers.TryGetValue(element, out var containerEntry) && ReferenceEquals(containerEntry.Node, node)) + { + heldBackContainers[element] = containerEntry with { PaddingBoxHeight = borderBoxHeight - borderTop - borderBottom }; + } + // A box with its own formatting context never collapses margins with its children. var canCollapseWithLastChild = borderBottom <= 0f && paddingBottom <= 0f && @@ -1657,6 +1774,13 @@ formControlKind is FormControlKind.Select or FormControlKind.Button || boxPaintBuffer.PushClip(clipRect, box.BorderRadius.ClampToBox(borderBoxWidth, borderBoxHeight)); } + var contentScroll = clipsOverflow ? GetContentScrollOffset(node.Ref) : default; + + if (contentScroll != default) + { + boxPaintBuffer.PushTransform(RenderTransform2D.Translate(-contentScroll.X, -contentScroll.Y)); + } + var paintInsertionIndex = boxPaintInsertIndex; if (!createsStackingContext && negativeZIndexRanges.Count > 0) @@ -1665,6 +1789,20 @@ formControlKind is FormControlKind.Select or FormControlKind.Button || } displayList.InsertRange(paintInsertionIndex, boxPaintBuffer.Commands); + + // the content painted below the box (negative z-index) is still clipped and scrolled by it + if (clipsOverflow && paintInsertionIndex != boxPaintInsertIndex) + { + var clipRect = new RenderRect(borderBoxX + borderLeft, borderBoxY + borderTop, Math.Max(0f, borderBoxWidth - borderLeft - borderRight), Math.Max(0f, borderBoxHeight - borderTop - borderBottom)); + + foreach (var (start, count) in negativeZIndexRanges.OrderByDescending(range => range.StartIndex)) + { + var clip = new PushClipCommand(clipRect, box.BorderRadius.ClampToBox(borderBoxWidth, borderBoxHeight)); + displayList.InsertRange(start + count, contentScroll != default ? [new PopTransformCommand(), new PopClipCommand()] : [new PopClipCommand()]); + displayList.InsertRange(start, contentScroll != default ? [clip, new PushTransformCommand(RenderTransform2D.Translate(-contentScroll.X, -contentScroll.Y))] : [clip]); + } + } + ResolveStickyBoxes(displayList, context.StickyScopes.Peek(), new RenderRect(contentX, contentY, contentWidth, contentHeight), context); if (TryResolveReplacedElementImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect, out var objectFitClipRect)) @@ -1726,6 +1864,12 @@ formControlKind is FormControlKind.Select or FormControlKind.Button || } } + if (contentScroll != default) + { + displayList.PopTransform(); + MoveScrolledContent(scrolledCapture, scrolledRecordStart, node.Ref, contentScroll); + } + if (clipsOverflow) { displayList.PopClip(); @@ -2051,6 +2195,9 @@ private static void LayoutFlexContainer( // paint behind its items, but its auto-sized height is only known after they are laid out // (and appended), so their paint commands are spliced in before this index instead. var boxPaintInsertIndex = displayList.Commands.Count; + // what the children record, which moves with the scroll position of a scroll container + var scrolledCapture = s_layoutCapture.Value; + var scrolledRecordStart = scrolledCapture?.RecordOrder.Count ?? 0; var flexDirection = GetFlexDirection(styleMap); var isRowDirection = !string.Equals(flexDirection, "column", StringComparison.OrdinalIgnoreCase) && !string.Equals(flexDirection, "column-reverse", StringComparison.OrdinalIgnoreCase); @@ -2450,6 +2597,7 @@ float EstimateCrossSize(FlexItemLayoutInfo item, float mainSize) var childTextIndent = false; var itemFloats = new FloatArea(); var itemRangeStart = displayList.Commands.Count; + RecordLink(node, elementChild, styleMap, item.Style, context); LayoutNode( node: elementChild, @@ -2534,8 +2682,17 @@ float EstimateCrossSize(FlexItemLayoutInfo item, float mainSize) contentHeight = Math.Min(contentHeight, maximumContentHeight); } + if (s_layoutCapture.Value is { Containers: { } containers }) + { + // what LayoutOutOfFlowChildren gives a child shown later (see FindLayoutRoot) + containers[node.Ref] = new ContainerEntry(node, containingX, borderBoxY + borderTop + paddingTop, containingWidth, inheritedTextStyle, maxY, float.NaN, + borderBoxX + borderLeft, borderBoxY + borderTop, paddingLeft + containingWidth + paddingRight, paddingTop + contentHeight + paddingBottom); + } + LayoutOutOfFlowChildren(contentHeight); paintOrder.Apply(displayList); + // the hidden children, which the flattened ones leave out + RecordPaintLayer(paintOrder, node.Children, displayList); var borderBoxWidth = borderLeft + paddingLeft + containingWidth + paddingRight + borderRight; var borderBoxHeight = borderTop + paddingTop + contentHeight + paddingBottom + borderBottom; var canCollapseWithLastChild = borderBottom <= 0f && paddingBottom <= 0f && float.IsNaN(specifiedContentHeight); @@ -2606,6 +2763,13 @@ float EstimateCrossSize(FlexItemLayoutInfo item, float mainSize) boxPaintBuffer.PushClip(clipRect, box.BorderRadius.ClampToBox(borderBoxWidth, borderBoxHeight)); } + var contentScroll = clipsOverflow ? GetContentScrollOffset(node.Ref) : default; + + if (contentScroll != default) + { + boxPaintBuffer.PushTransform(RenderTransform2D.Translate(-contentScroll.X, -contentScroll.Y)); + } + displayList.InsertRange(boxPaintInsertIndex, boxPaintBuffer.Commands); ResolveStickyBoxes( displayList, @@ -2644,6 +2808,12 @@ float EstimateCrossSize(FlexItemLayoutInfo item, float mainSize) } } + if (contentScroll != default) + { + displayList.PopTransform(); + MoveScrolledContent(scrolledCapture, scrolledRecordStart, node.Ref, contentScroll); + } + if (clipsOverflow) { displayList.PopClip(); @@ -2764,6 +2934,9 @@ private static void LayoutGridContainer( // paint behind its items, but its auto-sized height is only known after they are laid out // (and appended), so their paint commands are spliced in before this index instead. var boxPaintInsertIndex = displayList.Commands.Count; + // what the children record, which moves with the scroll position of a scroll container + var scrolledCapture = s_layoutCapture.Value; + var scrolledRecordStart = scrolledCapture?.RecordOrder.Count ?? 0; var columnGap = ResolveColumnGap(styleMap, containingWidth); var rowGap = ResolveRowGap(styleMap, ResolveGapHeightReference(styleMap, containingWidth, paddingTop + paddingBottom + borderTop + borderBottom)); @@ -2772,6 +2945,13 @@ private static void LayoutGridContainer( var gridItems = FlattenDisplayContents(node.Children, context) .Where(child => child is ElementRenderNode || (child is TextRenderNode textNode && NormalizeWhitespace(textNode.Ref.Data).Length > 0)) .ToList(); + // absolutely positioned children take no cell; they are laid out once the grid's size is known + var outOfFlowChildren = gridItems + .OfType() + .Where(child => IsOutOfFlowPositioned(GetOrCreateStyleMap(context, child.Ref, child.ComputedStyle))) + .ToList(); + gridItems.RemoveAll(child => child is ElementRenderNode element && outOfFlowChildren.Contains(element)); + var paintOrder = new PaintOrder(); var explicitGridTemplateColumns = styleMap.GetValueOrDefault("grid-template-columns") ?? string.Empty; var columnLineNames = new Dictionary>(StringComparer.Ordinal); @@ -3186,6 +3366,8 @@ static float PlaceInGridArea(GridSelfAlignment alignment, float marginStart, flo var childSuppressNextBlockTopMargin = false; var childFloats = new FloatArea(); var childTextIndentConsumed = false; + var itemRangeStart = displayList.Commands.Count; + RecordLink(node, elementChild, styleMap, childStyleMap, context); LayoutNode( node: elementChild, @@ -3206,6 +3388,7 @@ static float PlaceInGridArea(GridSelfAlignment alignment, float marginStart, flo flexMainSize: resolvedChildContentWidth, flexCrossSize: resolvedChildContentHeight, mainAxisMarginsPlaced: true); + paintOrder.Track(elementChild, displayList, itemRangeStart, context); } var gridContentWidth = GetGridContentSize(columnSizes, resolvedColumnGap, containingWidth); @@ -3222,6 +3405,10 @@ static float PlaceInGridArea(GridSelfAlignment alignment, float marginStart, flo resolvedGridContentHeight = Math.Min(resolvedGridContentHeight, maximumGridContentHeight); } var borderBoxHeight = borderTop + paddingTop + resolvedGridContentHeight + paddingBottom + borderBottom; + LayoutOutOfFlowChildren(); + paintOrder.Apply(displayList); + // the hidden children, which the flattened ones leave out + RecordPaintLayer(paintOrder, node.Children, displayList); // The margins of grid items never collapse with the grid's own (CSS Grid 1 §6.1). var effectiveMarginBottom = ParseLength(styleMap, "margin-bottom", containingWidth, box.Margin.Bottom, allowAuto: false); @@ -3285,6 +3472,13 @@ static float PlaceInGridArea(GridSelfAlignment alignment, float marginStart, flo boxPaintBuffer.PushClip(clipRect, box.BorderRadius.ClampToBox(borderBoxWidth, borderBoxHeight)); } + var contentScroll = clipsOverflow ? GetContentScrollOffset(node.Ref) : default; + + if (contentScroll != default) + { + boxPaintBuffer.PushTransform(RenderTransform2D.Translate(-contentScroll.X, -contentScroll.Y)); + } + displayList.InsertRange(boxPaintInsertIndex, boxPaintBuffer.Commands); ResolveStickyBoxes( displayList, @@ -3296,6 +3490,12 @@ static float PlaceInGridArea(GridSelfAlignment alignment, float marginStart, flo Math.Max(0f, borderBoxHeight - borderTop - borderBottom - paddingTop - paddingBottom)), context); + if (contentScroll != default) + { + displayList.PopTransform(); + MoveScrolledContent(scrolledCapture, scrolledRecordStart, node.Ref, contentScroll); + } + if (clipsOverflow) { displayList.PopClip(); @@ -3305,6 +3505,109 @@ static float PlaceInGridArea(GridSelfAlignment alignment, float marginStart, flo cursorY = flowBorderBoxY + borderBoxHeight; previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; suppressNextBlockTopMargin = false; + + // Laid out against the grid area of their lines (CSS Grid 1 §9.1): a line left out (or + // not in the grid) is the padding edge. Their static position is the start of the content box. + void LayoutOutOfFlowChildren() + { + var contentX = borderBoxX + borderLeft + paddingLeft; + var contentY = borderBoxY + borderTop + paddingTop; + var paddingBoxX = borderBoxX + borderLeft; + var paddingBoxY = borderBoxY + borderTop; + var paddingBoxWidth = borderBoxWidth - borderLeft - borderRight; + var paddingBoxHeight = paddingTop + resolvedGridContentHeight + paddingBottom; + + if (s_layoutCapture.Value is { Containers: { } containers }) + { + // what a child shown later is laid out from (see FindLayoutRoot) + containers[node.Ref] = new ContainerEntry(node, contentX, contentY, containingWidth, inheritedTextStyle, maxY, float.NaN, + paddingBoxX, paddingBoxY, paddingBoxWidth, paddingBoxHeight); + } + + foreach (var child in outOfFlowChildren) + { + var childStyleMap = GetOrCreateStyleMap(context, child.Ref, child.ComputedStyle); + var (columnStart, columnEnd, rowStart, rowEnd) = childStyleMap.TryGetValue("grid-area", out var areaName) && + namedAreas.TryGetValue(areaName.Trim().Trim('"', '\''), out var area) + ? (area.Column, area.Column + area.ColumnSpan, area.Row, area.Row + area.RowSpan) + : ParseAbsoluteGridLines(childStyleMap.GetValueOrDefault("grid-column"), columnLines) is var (startColumn, endColumn) && + ParseAbsoluteGridLines(childStyleMap.GetValueOrDefault("grid-row"), rowLines) is var (startRow, endRow) + ? (startColumn, endColumn, startRow, endRow) + : default((int?, int?, int?, int?)); + var areaLeft = GetLinePosition(columnStart, columnSizes, contentX + columnAlignment.Offset, resolvedColumnGap + columnAlignment.Spacing) ?? paddingBoxX; + var areaRight = GetLinePosition(columnEnd, columnSizes, contentX + columnAlignment.Offset, resolvedColumnGap + columnAlignment.Spacing) ?? paddingBoxX + paddingBoxWidth; + var areaTop = GetLinePosition(rowStart, rowSizes, contentY + rowAlignment.Offset, resolvedRowGap + rowAlignment.Spacing) ?? paddingBoxY; + var areaBottom = GetLinePosition(rowEnd, rowSizes, contentY + rowAlignment.Offset, resolvedRowGap + rowAlignment.Spacing) ?? paddingBoxY + paddingBoxHeight; + var childCursorY = contentY; + var childPreviousBottom = 0f; + var childSuppressMargin = false; + var childTextIndent = false; + var childRangeStart = displayList.Commands.Count; + + LayoutNode( + child, + contentX, + contentY, + containingWidth, + ref childCursorY, + ref childPreviousBottom, + ref childSuppressMargin, + new FloatArea(), + ref childTextIndent, + inheritedTextStyle, + context, + displayList, + maxY, + positionedContainingX: areaLeft, + positionedContainingY: areaTop, + positionedContainingWidth: Math.Max(0f, areaRight - areaLeft), + positionedContainingHeight: Math.Max(0f, areaBottom - areaTop)); + paintOrder.Track(child, displayList, childRangeStart, context); + } + } + + // where a line of the grid is, if it is one + static float? GetLinePosition(int? line, IReadOnlyList tracks, float start, float gap) => + line is { } index && index >= 0 && index <= tracks.Count + ? start + (index == 0 ? 0f : GetGridTrackOffset(tracks, index - 1, gap) + tracks[index - 1]) + : null; + } + + /// + /// The lines of a grid-column/grid-row value of an absolutely positioned box + /// (0-based, null for auto): a span counts from the other line, and a lone name + /// stands for both edges of its area. + /// + private static (int? Start, int? End) ParseAbsoluteGridLines(string? rawValue, GridLines lines) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return (null, null); + } + + var parts = rawValue.Split('/', 2); + var startTokens = SplitGridLineTokens(parts[0]); + var endTokens = parts.Length > 1 ? SplitGridLineTokens(parts[1]) : null; + + if (endTokens is null && startTokens.Length == 1 && !int.TryParse(startTokens[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out _) && + !string.Equals(startTokens[0], "auto", StringComparison.OrdinalIgnoreCase)) + { + endTokens = startTokens; + } + + var start = ResolveGridLine(startTokens, lines, "-start"); + var end = endTokens is null ? null : ResolveGridLine(endTokens, lines, "-end"); + + if (start is null && end is { } onlyEnd && ResolveGridSpan(startTokens) is { } startSpan) + { + start = onlyEnd - startSpan; + } + else if (end is null && start is { } onlyStart && endTokens is not null && ResolveGridSpan(endTokens) is { } endSpan) + { + end = onlyStart + endSpan; + } + + return start is { } first && end is { } last && last < first ? (last, first) : (start, end); } /// @@ -4736,6 +5039,17 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo } var childStyle = GetOrCreateStyleMap(context, elementChild.Ref, elementChild.ComputedStyle); + var childTextStyle = ResolveTextStyle(childStyle, inheritedTextStyle); + var formControlKind = ResolveFormControlKind(elementChild); + + // A form control's intrinsic size (e.g. a checkbox's box) is its default width/height, + // which LayoutElement only fills in when it lays the item out - too late for its flex + // base size, which would come from its (empty) content and squeeze it to nothing. + if (formControlKind != FormControlKind.None) + { + ApplyFormControlDefaults(formControlKind, elementChild.Ref, childStyle, childTextStyle, context); + } + // Needed so ResolveFlexBaseSize/ResolveFlexCrossSize can convert an authored border-box // width/height/flex-basis into this renderer's content-box convention using *this item's* // own border/padding - not the container's - before flex-grow/shrink ever runs, the same @@ -4759,7 +5073,6 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo // The content size along the main axis: max-content (or the image's width) for rows, the // laid-out height for columns; the min-content width feeds the automatic minimum. - var childTextStyle = ResolveTextStyle(childStyle, inheritedTextStyle); float contentMainSize; float contentMinimum; var hasDeferredMainSize = false; @@ -5519,8 +5832,12 @@ private static float ParseLineHeight(Dictionary styleMap, float private static void PrepareDocumentForRendering(IDocument document) { ArgumentNullException.ThrowIfNull(document); + PrepareElementsForRendering(document.All.OfType()); + } - foreach (var element in document.All.OfType()) + private static void PrepareElementsForRendering(IEnumerable elements) + { + foreach (var element in elements) { var styleAttribute = element.GetAttribute("style"); @@ -6600,7 +6917,10 @@ private static IEnumerable OrderChildrenForLayout(IEnumerable private sealed class PaintOrder { - private readonly List<(int Z, int Order, RenderCommand First, int Count)> _raised = []; + private readonly List<(int Z, int Order, RenderCommand First, int Count, IElement Element)> _raised = []; + + /// Gets the children moved to the end, in the order they paint (once applied). + public IReadOnlyList Applied { get; private set; } = []; public void Track(IRenderNode child, DisplayList displayList, int start, LayoutContext context) { @@ -6612,14 +6932,17 @@ public void Track(IRenderNode child, DisplayList displayList, int start, LayoutC if ((IsOutOfFlowPositioned(styleMap) || HasExplicitZIndex(styleMap)) && ParseZIndex(styleMap) >= 0) { - _raised.Add((ParseZIndex(styleMap), _raised.Count, displayList.Commands[start], count)); + _raised.Add((ParseZIndex(styleMap), _raised.Count, displayList.Commands[start], count, element.Ref)); } } } public void Apply(DisplayList displayList) { - foreach (var (_, _, first, count) in _raised.OrderBy(m => m.Z).ThenBy(m => m.Order)) + var ordered = _raised.OrderBy(m => m.Z).ThenBy(m => m.Order).ToList(); + Applied = ordered.Select(m => new RaisedChild(m.Element, m.Z, m.First, m.Count)).ToList(); + + foreach (var (_, _, first, count, _) in ordered) { var commands = displayList.Commands; diff --git a/src/AngleSharp.Renderer/IDomHarness.cs b/src/AngleSharp.Renderer/IDomHarness.cs index 4140463..e7acc9f 100644 --- a/src/AngleSharp.Renderer/IDomHarness.cs +++ b/src/AngleSharp.Renderer/IDomHarness.cs @@ -109,4 +109,14 @@ public interface IDomHarness /// /// The prepared frame. RenderedFrame PrepareFrame(int overscan = 0); + + /// + /// Brings the last frame () up to date with the changes of the + /// active document since, without laying out all of it: when they were confined to boxes + /// out of the flow, those are laid out again and replace their commands (e.g. a menu changing + /// its items), and when the boxes were not rendered then and are not now (e.g. a script + /// showing, measuring and hiding a dropdown again), the frame stays as it is. + /// + /// The frame showing the document now, or null if it needs . + RenderedFrame? UpdateFrame() => null; } diff --git a/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs b/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs index a733153..dfeef56 100644 --- a/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs +++ b/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs @@ -24,7 +24,13 @@ internal sealed class InteractiveHtmlRendererState : IDomHarness private (double X, double Y) _mousePosition; private IDocument? _metricsDocument; private MutationObserver? _metricsObserver; - private IReadOnlyDictionary? _metrics; + private HtmlRenderer.LayoutSnapshot? _metrics; + // the last frame and its layout (see UpdateFrame) + private RenderedFrame? _lastFrame; + private HtmlRenderer.LayoutSnapshot? _lastFrameLayout; + // the changes the observer delivered on its own, which the metrics have not seen yet + private readonly List _pendingMutations = []; + private const int MaxPendingMutations = 256; private (int Width, int Height) _metricsViewport; private sealed class ElementInteractionState @@ -75,9 +81,10 @@ public InteractiveHtmlRendererState(IBrowsingContext context, IRenderDevice rend _mousePosition = value; var hoverChanged = UpdateHoveredElementFromMousePosition(); + // the hover state is what styles (and so the layout) depend on if (!hoverChanged) { - OnPaintInvalidated(); + OnPaintInvalidated(keepsMetrics: true); } } } @@ -109,7 +116,7 @@ public void SetScrollLeft(IElement element, double value, double maxLeft) } state.ScrollLeft = next; - OnPaintInvalidated(); + OnPaintInvalidated(keepsMetrics: IsPage(element)); } /// @@ -139,7 +146,7 @@ public void SetScrollTop(IElement element, double value, double maxTop) } state.ScrollTop = next; - OnPaintInvalidated(); + OnPaintInvalidated(keepsMetrics: IsPage(element)); } public void SetHoveredElement(IElement? element) @@ -296,13 +303,21 @@ public RenderedFrame PrepareFrame(int overscan = 0) using (_animationTracker.BeginFrame()) { - var frame = _renderer.PrepareFrame(targetDocument, RenderDevice, scrollTop, _isFrameScrollShiftable ? overscan : 0); + var frame = _renderer.PrepareFrame(targetDocument, RenderDevice, scrollTop, _isFrameScrollShiftable ? overscan : 0, out var snapshot); if (frame.OverscanTop > 0 && !frame.IsScrollShiftable) { - frame = _renderer.PrepareFrame(targetDocument, RenderDevice, scrollTop, 0); + frame = _renderer.PrepareFrame(targetDocument, RenderDevice, scrollTop, 0, out snapshot); } + // the frame's layout answers the geometry reads until something changes it + WatchMutations(targetDocument); + _metricsObserver!.Flush(); + _pendingMutations.Clear(); + _metrics = snapshot; + _metricsViewport = (RenderDevice.ViewPortWidth, RenderDevice.ViewPortHeight); + _lastFrame = frame; + _lastFrameLayout = snapshot; _isFrameScrollShiftable = frame.IsScrollShiftable; frame.IsAnimating = _animationTracker.IsAnimating || _transitionTracker.HasRunningTransitions; return frame; @@ -315,46 +330,119 @@ public RenderedFrame PrepareFrame(int overscan = 0) /// over and over, often for many elements in a row, and every capture is a full layout. /// /// - /// The metrics are dropped by a mutation of the document (taken synchronously, so a script - /// changing a class and reading geometry right after sees the change), by anything that - /// invalidates the paint here (scrolling, hovering, time), by a frame being prepared (which - /// happens after resources such as style sheets, images, or fonts arrived), and by a - /// changed viewport. + /// The metrics follow the mutations of the document (taken synchronously, so a script + /// changing a class and reading geometry right after sees the change): a change confined to + /// an out-of-flow box lays out only that box again (see ), + /// anything else all of the page. They are dropped by anything that invalidates the paint + /// here (hovering, time; not scrolling or moving the pointer) and by a changed viewport. A + /// frame being prepared (which happens after resources such as style sheets, images, or + /// fonts arrived) replaces them with its own layout. /// - internal IReadOnlyDictionary GetLayoutMetrics(IDocument document) + internal IReadOnlyDictionary GetLayoutMetrics(IDocument document) => + GetLayoutSnapshot(document).Metrics; + + /// + /// Gets the layout of the given document (see ), which also + /// maps its boxes into the viewport. + /// + internal HtmlRenderer.LayoutSnapshot GetLayoutSnapshot(IDocument document) { ArgumentNullException.ThrowIfNull(document); var viewport = (RenderDevice.ViewPortWidth, RenderDevice.ViewPortHeight); - if (!ReferenceEquals(_metricsDocument, document)) + WatchMutations(document); + KeepMutations(_metricsObserver!.Flush()); + var metrics = _metrics; + + if (_pendingMutations.Count > 0 && metrics is not null && (_metricsViewport != viewport || !metrics.TryUpdate(_pendingMutations))) { - _metricsObserver?.Disconnect(); - _metricsObserver = new MutationObserver((_, _) => _metrics = null); - _metricsObserver.Connect(document, childList: true, subtree: true, attributes: true, characterData: true); - _metricsDocument = document; - _metrics = null; + metrics = null; + } + + if (metrics is null || _metricsViewport != viewport) + { + metrics = _renderer.CaptureLayout(document, RenderDevice); + _metricsViewport = viewport; + } + + // the changes the layout made itself (see PrepareDocumentForRendering) are in already + _metricsObserver.Flush(); + _pendingMutations.Clear(); + _metrics = metrics; + return metrics; + } + + public RenderedFrame? UpdateFrame() + { + var document = Context.Active; + + if (document is null || _lastFrame is null || !ReferenceEquals(document, _metricsDocument) || _metricsObserver is null || + _metricsViewport != (RenderDevice.ViewPortWidth, RenderDevice.ViewPortHeight)) + { + return null; + } + + KeepMutations(_metricsObserver.Flush()); + + // only the frame's own layout (kept up to date since) knows where its boxes are painted + if (_metrics is not { } metrics || !ReferenceEquals(metrics, _lastFrameLayout)) + { + return null; } - else if (_metricsObserver!.Flush().ToArray().Length > 0) + + if (_pendingMutations.Count > 0 && !metrics.TryUpdate(_pendingMutations)) { _metrics = null; + _pendingMutations.Clear(); + return null; } - var metrics = _metrics; + _pendingMutations.Clear(); + _lastFrame = _renderer.UpdateFrame(document, _lastFrame, metrics); + return _lastFrame; + } - if (metrics is null || _metricsViewport != viewport) + private void WatchMutations(IDocument document) + { + if (!ReferenceEquals(_metricsDocument, document)) { - metrics = HtmlRenderer.CaptureLayoutMetrics(document, RenderDevice); - _metrics = metrics; - _metricsViewport = viewport; + _metricsObserver?.Disconnect(); + _metricsObserver = new MutationObserver((records, _) => KeepMutations(records)); + _metricsObserver.Connect(document, childList: true, subtree: true, attributes: true, characterData: true, attributeOldValue: true); + _metricsDocument = document; + _metrics = null; } + } - return metrics; + // Many changes before the next read are cheaper laid out all at once, and kept around + // without one they would only pile up. + private void KeepMutations(IEnumerable records) + { + if (_metrics is not null) + { + _pendingMutations.AddRange(records); + + if (_pendingMutations.Count > MaxPendingMutations) + { + _metrics = null; + _pendingMutations.Clear(); + } + } } - private void OnPaintInvalidated() + // the page scrolls in the viewport, other scroll containers move their content in the layout + private static bool IsPage(IElement element) => ReferenceEquals(element, element.Owner?.DocumentElement); + + // the metrics are laid out unscrolled and only depend on the hovered element, not on where + // the pointer is (see GetLayoutMetrics), so scrolling the page and moving the pointer keep them + private void OnPaintInvalidated(bool keepsMetrics = false) { - _metrics = null; + if (!keepsMetrics) + { + _metrics = null; + } + PaintInvalidated?.Invoke(this, EventArgs.Empty); } diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index 1bb78da..bdf095d 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -44,6 +44,15 @@ public void InsertRange(int index, IEnumerable commands) _commands.InsertRange(index, commands); } + /// + /// Replaces the commands of a range with others (e.g. a box laid out again). + /// + internal void ReplaceRange(int index, int count, IEnumerable commands) + { + _commands.RemoveRange(index, count); + _commands.InsertRange(index, commands); + } + /// /// Removes the commands from the given index on, e.g. the ones of a layout to be redone. /// diff --git a/src/AngleSharp.Renderer/Rendering/RenderedFrame.cs b/src/AngleSharp.Renderer/Rendering/RenderedFrame.cs index 44285cc..ec5075d 100644 --- a/src/AngleSharp.Renderer/Rendering/RenderedFrame.cs +++ b/src/AngleSharp.Renderer/Rendering/RenderedFrame.cs @@ -76,6 +76,9 @@ internal RenderedFrame(DisplayList displayList, IRenderBackend backend, RenderVi /// public bool IsAnimating { get; internal set; } + /// Gets the commands of the frame, as laid out (before any shift). + internal DisplayList DisplayList => _displayList; + /// /// Gets what is painted where, in viewport coordinates of this frame. /// diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 6f8bd7d..57f944e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ Adds rendering functionality to the core AngleSharp library. AngleSharp.Renderer - 0.9.1 + 0.9.2 enable latest true