Skip to content
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 1.14.0

- **`box-sizing`** now applies ([#33](https://github.com/brewkits/hyper_render/issues/33)): `border-box` includes padding and border in `width` / `min-width` / `max-width`, including `%`, on in-flow blocks. Limits are in `doc/LIMITATIONS.md`.
- **`position: relative`** now applies ([#34](https://github.com/brewkits/hyper_render/issues/34)): `top` / `right` / `bottom` / `left` (lengths) move blocks, inline spans and flex/grid items without affecting layout, and hit-testing and selection follow. Moved content paints above normal content and is clipped to the render box, so untrusted HTML can't paint over the host app. Other content stays unclipped.
- **Rendering changes:**
- A block's background and border now follow its `width`. They used to span the whole container.
- `min-width: %` now applies on blocks.
- A first block styled only with a background or border now paints it.
- Gradients on canvas blocks and inline spans now paint. They painted nothing.
- A `<details>` toggle no longer leaves a block's old background behind.
- `HyperViewer(textDirection:)` and an ambient RTL `Directionality` now apply to short documents in sync mode. Virtualized and paged modes already honoured them.
- **Requires `hyper_render_core` `^1.14.0`.**

## 1.13.1

Packaging and documentation only; no code changes in the library.
Expand Down
4 changes: 2 additions & 2 deletions doc/CSS_PROPERTIES_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ This document lists CSS property support in HyperRender.
| `border-style` | ✅ | solid, dashed, dotted, double, none | Custom styles beyond Flutter defaults |
| `border-color` | ✅ | All color formats | |
| `border-radius` | ✅ | px, % | All 4 corners + shorthand |
| `box-sizing` | ❌ | — | Parsed, not applied: `width` always sizes the content box, so `border-box` is ignored (measured: `width:200px;padding:20px` wraps at 200px with or without it) |
| `box-sizing` | ⚠️ | content-box, border-box | Applied to `width` / `min-width` / `max-width`, including `%`, on in-flow blocks. Not applied to flex/grid containers, tables or images. Borders don't reserve layout space, so text can overlap a border. See [LIMITATIONS.md](LIMITATIONS.md) |
| `aspect-ratio` | ✅ | W/H, bare number, auto | Applied to img/video sizing (v1.4.0) |

---
Expand All @@ -59,7 +59,7 @@ This document lists CSS property support in HyperRender.
| `opacity` | ✅ | 0-1 | |
| `overflow` | ⚠️ | hidden, visible | No scroll support |
| `position: static` | ✅ | static | Default |
| `position: relative` | ❌ | — | Parsed, not applied: `top` / `left` offsets do not move the element |
| `position: relative` | ⚠️ | top, right, bottom, left (lengths) | Moves blocks, inline spans, and flex/grid items; layout, hit-testing and selection follow. No `%` offsets. Moved content is clipped to the render box. See [LIMITATIONS.md](LIMITATIONS.md) |
| `position: absolute` | ❌ | — | Use `pluginRegistry` for overlay widgets |
| `position: fixed` | ❌ | — | Use `pluginRegistry` for overlay widgets |
| `top` / `right` / `bottom` / `left` | ❌ | — | Requires absolute/fixed; not supported |
Expand Down
4 changes: 3 additions & 1 deletion doc/LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ are silently ignored.
| Property | Limitation |
|----------|------------|
| `opacity` | Applied per-element; stacking context opacity not propagated |
| `position: relative` / `box-sizing: border-box` | Parsed but not applied: no offset for `relative`, and `width` always sizes the content box |
| `box-sizing: border-box` | Applied on in-flow blocks only. Borders don't reserve layout space, so text can overlap a border. A flex item's `width` already includes its padding whatever `box-sizing` says. `width` has no effect on a flex or grid container, `<table>` or `<td>`, and floated images ignore padding, so `box-sizing` changes nothing there |
| Horizontal block `margin` | `margin-left`, `margin-right` and `margin: 0 auto` have no effect on an in-flow block, so a block with a `width` always sits at the left edge and can't be centred this way |
| `position: relative` | Lengths only: `%` offsets are ignored. Moved content paints above normal content but in no finer order (no `z-index` or stacking contexts), and is clipped to the render box so it can't paint over the host app (the sanitizer doesn't filter CSS properties). In virtualized mode that box is one chunk. A flex or grid item moved past its container is still visible inside the box. Not applied to `<td>`. A large container flattened for virtualization loses its offset |
| `calc()` | Arithmetic on px/em/rem only; `%` in calc not resolved |
| `text-align` | Center/right/justify on canvas paragraphs, LTR and box-level RTL (`textDirection: rtl`). An explicit `text-align` overrides RTL's default right edge; `justify` in RTL is not yet distributed (keeps the start edge) |
| `float` scope | A float reserves a box from the element's own `width`/`height`; it does **not** lay the floated element's content out inside that box. So float works for replaced elements (`<img style="float:left">`) and for empty, explicitly sized boxes (`<div style="float:left;width:150px;height:120px">`) — text wraps beside both. It does **not** work for an element sized by its own text: a floated `<div>` with content renders that content in normal flow while still reserving a phantom float box, which can make the block *taller* than not floating it; a floated inline `<span>` (the drop-cap idiom) has no effect at all. Pinned by `test/flagship_execution_audit_test.dart` |
Expand Down
4 changes: 2 additions & 2 deletions doc/SUPPORTED_HTML.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ or the `fallbackBuilder` parameter to delegate to a WebView or other renderer.
| `border` | width, style, color |
| `border-style` | solid, dashed, dotted, double, none |
| `border-radius` | px, % |
| `box-sizing` | **Not applied** — `width` always sizes the content box |
| `box-sizing` | content-box, border-box on in-flow blocks ([limits](LIMITATIONS.md)) |
| `overflow` | visible, hidden |

### Layout
Expand All @@ -107,7 +107,7 @@ or the `fallbackBuilder` parameter to delegate to a WebView or other renderer.
| `display` | block, inline, inline-block, none, flex, grid, table |
| `float` | left, right, none (Full wrapping support) |
| `clear` | left, right, both, none |
| `position` | **Not applied** — `static` flow only (no `relative` offsets, `absolute`, `fixed`) |
| `position` | `static`, `relative` with length offsets ([limits](LIMITATIONS.md)). `absolute` and `fixed` lay out as `static` |
| Flexbox | Containers and items, incl. wrapping flex on a dedicated `RenderFlexWrap` (v1.9.0). Not applied: `align-content`; `flex-basis` drives wrapping containers only |
| CSS Grid | `grid-template-columns/rows` (px, fr, auto, repeat), `grid-column/row` span, gap (v1.2.0). Not applied: `grid-auto-flow`, `align-content` |

Expand Down
1 change: 1 addition & 0 deletions lib/hyper_render.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export 'package:hyper_render_core/hyper_render_core.dart'
HyperBorderCollapse,
HyperOverflow,
HyperFloat,
HyperBoxSizing,
HyperClear,
HyperTimingFunction,
HyperTimingParams,
Expand Down
2 changes: 2 additions & 0 deletions lib/src/widgets/hyper_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2096,6 +2096,7 @@ class _HyperViewerState extends State<HyperViewer>
if (widget.selectable && widget.showSelectionMenu) {
content = HyperSelectionOverlay(
document: _syncDocument!,
textDirection: widget.textDirection,
selectable: true,
onLinkTap: _safeOnLinkTap,
widgetBuilder: _effectiveWidgetBuilder,
Expand All @@ -2120,6 +2121,7 @@ class _HyperViewerState extends State<HyperViewer>
// Use HyperRenderWidget directly (no popup menu)
content = HyperRenderWidget(
document: _syncDocument!,
textDirection: widget.textDirection ?? Directionality.of(context),
selectable: widget.selectable,
onLinkTap: _safeOnLinkTap,
widgetBuilder: _effectiveWidgetBuilder,
Expand Down
6 changes: 6 additions & 0 deletions packages/hyper_render_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog — hyper_render_core

## 1.14.0

- **`box-sizing`:** new `ComputedStyle.boxSizing` / `HyperBoxSizing`. `border-box` applies to `width` / `min-width` / `max-width`, including `%`, on in-flow blocks.
- **`position: relative`:** `top` / `right` / `bottom` / `left` (lengths) move blocks, inline spans and flex/grid items without affecting layout. New `ComputedStyle.insetTop` / `insetRight` / `insetBottom` / `insetLeft` and `relativeOffset()`. Hit-testing and selection follow; moved content paints above normal content, clipped to the render box.
- **Behavior changes:** a block's background and border follow its `width`; `min-width: %` applies on blocks; a first block styled only with a background or border paints it; canvas gradients paint; a `<details>` toggle no longer leaves an old background behind.

## 1.13.0

- **`RenderHyperBox.getBoxesForCharRange(charStart, charEnd)`** — bounding boxes for a character range, for app-drawn highlights (search hits, annotations, read-aloud). Offsets are the selection/IME character space, local to this `RenderHyperBox`: in virtualized / `auto` mode (>10k chars) each chunk has its own box and offsets restart at 0. Glyph x-bounds come from `TextPainter` (so they follow `textScaler` and justified spacing); y-bounds span the full line. Rects of adjacent words on a line merge, except across an inline atom such as an image. Spaces inside `white-space: pre` / `pre-wrap` / `break-spaces` are kept. By [@DrkXo](https://github.com/DrkXo) ([#26](https://github.com/brewkits/hyper_render/pull/26), follow-up to [#17](https://github.com/brewkits/hyper_render/pull/17)).
Expand Down
98 changes: 63 additions & 35 deletions packages/hyper_render_core/lib/src/core/render_hyper_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,39 @@ class RenderHyperBox extends RenderBox
/// Block decorations for painting border-left, backgrounds
final List<_BlockDecoration> _blockDecorations = [];

/// Whether the document has any `position: relative` element, including
/// inside flex and grid containers. Documents without one skip all of the
/// relative handling.
bool _hasRelative = false;

/// Flex and grid containers holding a `position: relative` item. Their items
/// move inside a child widget, invisible to the fragment pass, so the whole
/// container paints with the moved content.
final Set<UDTNode> _relativeWidgetBoxes = Set.identity();

/// Cumulative `position: relative` shift of each node in this box's document.
final Map<UDTNode, Offset> _relativeShifts = Map.identity();

/// Every fragment `position: relative` moved, with how far. Used to undo the
/// shift before the next line layout, since not every fragment gets re-placed.
final Map<Fragment, Offset> _displaced = Map.identity();

/// The moved fragments that sit on a line. They stay listed on that line for
/// document order and selection, so anything that finds a fragment by line
/// must check this list as well.
final List<Fragment> _displacedOnLines = [];

/// Whether any painted thing moved, which turns on the clipped paint pass.
bool _hasMovedContent = false;

/// Clips the moved-content paint pass to this box. See [_paint].
final LayerHandle<ClipRectLayer> _relativeClipLayer =
LayerHandle<ClipRectLayer>();

/// Whether `position: relative` moved any content in the last layout.
@visibleForTesting
bool get debugHasDisplacedContent => _hasMovedContent;

/// Runtime animation state for every node currently carrying a resolvable
/// `animation-name` (canvas-painted block animation — see
/// render_hyper_box_animation.dart). Rebuilt on every [performLayout].
Expand Down Expand Up @@ -726,6 +759,7 @@ class RenderHyperBox extends RenderBox

@override
void dispose() {
_relativeClipLayer.layer = null;
// Cancel pending shimmer frame callback to prevent callbacks on a disposed
// RenderObject. SchedulerBinding.cancelFrameCallbackWithId is safe to call
// with an id that has already fired (it's a no-op in that case).
Expand Down Expand Up @@ -1281,6 +1315,9 @@ class RenderHyperBox extends RenderBox
// Step 4: Position fragments within lines (baseline alignment)
_positionFragments();

// Step 4.5: Apply position: relative, once line positions are final.
_applyRelativeOffsets();

// Step 5: Build inline decorations
_buildInlineDecorations();

Expand All @@ -1296,6 +1333,7 @@ class RenderHyperBox extends RenderBox
// text content and constraint width are both unchanged.
_performLineLayout();
_positionFragments();
_applyRelativeOffsets();
_buildInlineDecorations();
_buildCharacterMapping();
}
Expand Down Expand Up @@ -1326,7 +1364,9 @@ class RenderHyperBox extends RenderBox
final offset = fragment.offset;
final measured = fragment.measuredSize;
if (offset != null && measured != null) {
final bottom = offset.dy + measured.height + 4; // +4 = block margin
// In-flow position: `position: relative` must not change the height.
final flowY = offset.dy - (_displaced[fragment]?.dy ?? 0);
final bottom = flowY + measured.height + 4; // +4 = block margin
if (bottom > height) height = bottom;
}
}
Expand Down Expand Up @@ -1429,43 +1469,31 @@ class RenderHyperBox extends RenderBox

void _paint(PaintingContext context, Offset offset) {
try {
final canvas = context.canvas;

// -1. Animated blocks (CSS animation-name): each block's own decoration
// + text painted as one composited, transformed/faded unit. Populates
// _paintedThisFrameByAnimation so steps 0 and 4 below skip repainting
// the same content at its static style.
_paintAnimatedBlocks(canvas, offset);

// CSS Stacking Order:
// 0. Block decorations (border-left for blockquote, etc.)
_paintBlockDecorations(canvas, offset);

// 1. Background and borders (inline decorations)
_paintInlineDecorations(canvas, offset);

// 2. Float elements (painted before inline content)
_paintFloatImages(canvas, offset);

// 3. Selection highlight (behind text)
if (_selection != null &&
_selection!.isValid &&
!_selection!.isCollapsed) {
_paintSelection(canvas, offset);
_paintedThisFrameByAnimation.clear();
_paintPass(context, offset, moved: false);

// `position: relative` content goes last, which stacks it above normal
// content as in CSS, and in its own clip. It can be moved anywhere and the
// sanitizer doesn't filter CSS properties, so untrusted HTML must not be
// able to draw over the host app. Content outside this box isn't
// hit-testable either, so what's visible stays what's tappable. Normal
// content stays unclipped: one moved span mustn't crop a box-shadow
// elsewhere in the document.
if (_hasMovedContent) {
_relativeClipLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
(context, offset) => _paintPass(context, offset, moved: true),
oldLayer: _relativeClipLayer.layer,
);
} else {
_relativeClipLayer.layer = null;
}

// 4. Inline content (text fragments)
_paintTextFragments(canvas, offset);

// 5. Inline images (non-float)
_paintInlineImages(canvas, offset);

// 6. Child render boxes (tables, positioned elements)
defaultPaint(context, offset);

// 7. Debug bounds overlay (only when debugShowBounds is true)
// Debug bounds overlay (only when debugShowBounds is true)
if (debugShowBounds) {
_paintDebugBounds(canvas, offset);
_paintDebugBounds(context.canvas, offset);
}
} catch (e, stack) {
// Error boundary: prevent full app crash, but report to Flutter framework
Expand Down
Loading
Loading