buildWebNativeImage function

Widget buildWebNativeImage({
  1. required String imageUrl,
  2. BoxFit fit = BoxFit.cover,
  3. double? height,
  4. double? width,
  5. Widget? placeholder,
  6. Widget? errorWidget,
  7. bool circular = false,
})

Builds a native HTML element via HtmlElementView. This bypasses CanvasKit's CORS requirement for cross-origin images. Includes error handling: on load failure, hides the broken image icon and sets a neutral background so Flutter's placeholder/error widget shows through.

Implementation

Widget buildWebNativeImage({
  required String imageUrl,
  BoxFit fit = BoxFit.cover,
  double? height,
  double? width,
  Widget? placeholder,
  Widget? errorWidget,
  bool circular = false,
}) {
  if (imageUrl.isEmpty || imageUrl == 'null') {
    return errorWidget ?? const SizedBox.shrink();
  }

  final optimizedUrl = _optimizeGoogleUrl(imageUrl);
  final suffix = circular ? '-circle' : '';
  final viewType = 'web-img$suffix-${optimizedUrl.hashCode}';

  if (!_registeredFactories.contains(viewType)) {
    _registeredFactories.add(viewType);
    ui_web.platformViewRegistry.registerViewFactory(
      viewType,
      (int viewId) {
        final img = html.ImageElement()
          ..src = optimizedUrl
          ..style.width = '100%'
          ..style.height = '100%'
          ..style.objectFit = _boxFitToCss(fit)
          ..style.display = 'block'
          ..style.pointerEvents = 'none';

        if (circular) {
          img.style.borderRadius = '50%';
        }

        // On error (429, 404, CORS, etc.), hide the broken image icon
        // and show a neutral background instead.
        img.onError.listen((_) {
          img.style.display = 'none';
        });

        return img;
      },
    );
  }

  final Widget imageView = HtmlElementView(viewType: viewType);

  // When BOTH explicit dimensions are provided, use SizedBox to constrain.
  if (height != null && width != null) {
    return SizedBox(
      height: height,
      width: width,
      child: Stack(
        children: [
          Positioned.fill(child: imageView),
          Positioned.fill(child: ColoredBox(color: const Color(0x00000000))),
        ],
      ),
    );
  }
  // When only one dimension is provided, fall through to LayoutBuilder
  // which handles unbounded constraints safely.

  // ⚠️ DO NOT CHANGE THIS TO AspectRatio OR any other constrained wrapper.
  // SizedBox.expand is REQUIRED so the HTML <img> fills its parent constraints
  // (e.g. Stack/Positioned.fill in Librinder cards, profile covers, etc.).
  // The CSS object-fit:cover handles scaling. If you wrap in AspectRatio,
  // images won't cover full-bleed containers on web. — 2026-03-23
  //
  // HtmlElementView absorbs pointer events even with CSS pointerEvents:none.
  // The transparent overlay lets parent GestureDetectors receive taps.
  // Wrap in LayoutBuilder to get parent constraints.
  // If parent provides finite constraints, expand to fill.
  // If parent is unbounded (ListView/Column), use a default size.
  return LayoutBuilder(
    builder: (context, constraints) {
      final hasFiniteSize = constraints.hasBoundedHeight && constraints.hasBoundedWidth;
      final effectiveWidth = width ?? (constraints.hasBoundedWidth ? constraints.maxWidth : 300.0);
      final effectiveHeight = height ?? (constraints.hasBoundedHeight ? constraints.maxHeight : 200.0);
      final child = Stack(
        children: [
          if (hasFiniteSize && width == null && height == null)
            SizedBox.expand(child: imageView)
          else
            SizedBox(
              width: effectiveWidth,
              height: effectiveHeight,
              child: imageView,
            ),
          Positioned.fill(
            child: ColoredBox(color: const Color(0x00000000)),
          ),
        ],
      );
      return (hasFiniteSize && width == null && height == null) ? child : SizedBox(
        width: effectiveWidth,
        height: effectiveHeight,
        child: child,
      );
    },
  );
}