blob: a9fbd6368a6cad80d5b87f1feeca7a7d0c3ff28a [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.12
part of engine;
/// A raw HTML canvas that is directly written to.
class BitmapCanvas extends EngineCanvas {
/// The rectangle positioned relative to the parent layer's coordinate
/// system's origin, within which this canvas paints.
///
/// Painting outside these bounds will result in cropping.
ui.Rect get bounds => _bounds;
set bounds(ui.Rect newValue) {
assert(newValue != null); // ignore: unnecessary_null_comparison
_bounds = newValue;
final int newCanvasPositionX = _bounds.left.floor() - kPaddingPixels;
final int newCanvasPositionY = _bounds.top.floor() - kPaddingPixels;
if (_canvasPositionX != newCanvasPositionX ||
_canvasPositionY != newCanvasPositionY) {
_canvasPositionX = newCanvasPositionX;
_canvasPositionY = newCanvasPositionY;
_updateRootElementTransform();
}
}
ui.Rect _bounds;
CrossFrameCache<html.HtmlElement>? _elementCache;
/// The amount of padding to add around the edges of this canvas to
/// ensure that anti-aliased arcs are not clipped.
static const int kPaddingPixels = 1;
@override
final html.Element rootElement = html.Element.tag('flt-canvas');
final _CanvasPool _canvasPool;
/// The size of the paint [bounds].
ui.Size get size => _bounds.size;
/// The last CSS font string is cached to optimize the case where the font
/// styles hasn't changed.
String? _cachedLastCssFont = null;
/// List of extra sibling elements created for paragraphs and clipping.
final List<html.Element> _children = <html.Element>[];
/// The number of pixels along the width of the bitmap that the canvas element
/// renders into.
///
/// These pixels are different from the logical CSS pixels. Here a pixel
/// literally means 1 point with a RGBA color.
final int _widthInBitmapPixels;
/// The number of pixels along the width of the bitmap that the canvas element
/// renders into.
///
/// These pixels are different from the logical CSS pixels. Here a pixel
/// literally means 1 point with a RGBA color.
final int _heightInBitmapPixels;
/// The number of pixels in the bitmap that the canvas element renders into.
///
/// These pixels are different from the logical CSS pixels. Here a pixel
/// literally means 1 point with a RGBA color.
int get bitmapPixelCount => _widthInBitmapPixels * _heightInBitmapPixels;
int _saveCount = 0;
/// Keeps track of what device pixel ratio was used when this [BitmapCanvas]
/// was created.
final double _devicePixelRatio =
EnginePlatformDispatcher.browserDevicePixelRatio;
// Compensation for [_initializeViewport] snapping canvas position to 1 pixel.
int? _canvasPositionX, _canvasPositionY;
// Indicates the instructions following drawImage or drawParagraph that
// a child element was created to paint.
// TODO(flutter_web): When childElements are created by
// drawImage/drawParagraph commands, compositing order is not correctly
// handled when we interleave these with other paint commands.
// To solve this, recording canvas will have to check the paint queue
// and send a hint to EngineCanvas that additional canvas layers need
// to be used to composite correctly. In practice this is very rare
// with Widgets but CustomPainter(s) can hit this code path.
bool _childOverdraw = false;
/// Forces text to be drawn using HTML rather than bitmap.
///
/// Use this for tests only.
set debugChildOverdraw(bool value) {
_childOverdraw = value;
}
/// Indicates bitmap canvas contains a 3d transform.
/// WebKit fails to preserve paint order when this happens and therefore
/// requires insertion of <div style="transform: translate3d(0,0,0);"> to be
/// used for each child to force correct rendering order.
bool _contains3dTransform = false;
/// Indicates that contents should be rendered into canvas so a dataUrl
/// can be constructed from contents.
bool _preserveImageData = false;
/// Canvas pixel to screen pixel ratio. Similar to dpi but
/// uses global transform of canvas to compute ratio.
final double _density;
final RenderStrategy _renderStrategy;
/// Allocates a canvas with enough memory to paint a picture within the given
/// [bounds].
///
/// This canvas can be reused by pictures with different paint bounds as long
/// as the [Rect.size] of the bounds fully fit within the size used to
/// initialize this canvas.
BitmapCanvas(this._bounds, RenderStrategy renderStrategy,
{double density = 1.0})
: assert(_bounds != null), // ignore: unnecessary_null_comparison
_density = density,
_renderStrategy = renderStrategy,
_widthInBitmapPixels = _widthToPhysical(_bounds.width),
_heightInBitmapPixels = _heightToPhysical(_bounds.height),
_canvasPool = _CanvasPool(_widthToPhysical(_bounds.width),
_heightToPhysical(_bounds.height), density) {
rootElement.style.position = 'absolute';
// Adds one extra pixel to the requested size. This is to compensate for
// _initializeViewport() snapping canvas position to 1 pixel, causing
// painting to overflow by at most 1 pixel.
_canvasPositionX = _bounds.left.floor() - kPaddingPixels;
_canvasPositionY = _bounds.top.floor() - kPaddingPixels;
_updateRootElementTransform();
_canvasPool.allocateCanvas(rootElement as html.HtmlElement);
_setupInitialTransform();
}
/// Constructs bitmap canvas to capture image data.
factory BitmapCanvas.imageData(ui.Rect bounds) {
BitmapCanvas bitmapCanvas = BitmapCanvas(bounds, RenderStrategy());
bitmapCanvas._preserveImageData = true;
return bitmapCanvas;
}
/// Setup cache for reusing DOM elements across frames.
void setElementCache(CrossFrameCache<html.HtmlElement>? cache) {
_elementCache = cache;
}
void _updateRootElementTransform() {
// Flutter emits paint operations positioned relative to the parent layer's
// coordinate system. However, canvas' coordinate system's origin is always
// in the top-left corner of the canvas. We therefore need to inject an
// initial translation so the paint operations are positioned as expected.
//
// The flooring of the value is to ensure that canvas' top-left corner
// lands on the physical pixel. TODO: !This is not accurate if there are
// transforms higher up in the stack.
rootElement.style.transform =
'translate(${_canvasPositionX}px, ${_canvasPositionY}px)';
}
void _setupInitialTransform() {
final double canvasPositionCorrectionX = _bounds.left -
BitmapCanvas.kPaddingPixels -
_canvasPositionX!.toDouble();
final double canvasPositionCorrectionY = _bounds.top -
BitmapCanvas.kPaddingPixels -
_canvasPositionY!.toDouble();
// This compensates for the translate on the `rootElement`.
_canvasPool.initialTransform = ui.Offset(
-_bounds.left + canvasPositionCorrectionX + BitmapCanvas.kPaddingPixels,
-_bounds.top + canvasPositionCorrectionY + BitmapCanvas.kPaddingPixels,
);
}
static int _widthToPhysical(double width) {
final double boundsWidth = width + 1;
return (boundsWidth * EnginePlatformDispatcher.browserDevicePixelRatio)
.ceil() +
2 * kPaddingPixels;
}
static int _heightToPhysical(double height) {
final double boundsHeight = height + 1;
return (boundsHeight * EnginePlatformDispatcher.browserDevicePixelRatio)
.ceil() +
2 * kPaddingPixels;
}
// Used by picture to assess if canvas is large enough to reuse as is.
bool doesFitBounds(ui.Rect newBounds, double newDensity) {
assert(newBounds != null); // ignore: unnecessary_null_comparison
return _widthInBitmapPixels >= _widthToPhysical(newBounds.width) &&
_heightInBitmapPixels >= _heightToPhysical(newBounds.height) &&
_density == newDensity;
}
@override
void dispose() {
_canvasPool.dispose();
}
/// Prepare to reuse this canvas by clearing it's current contents.
@override
void clear() {
_contains3dTransform = false;
_canvasPool.clear();
final int len = _children.length;
for (int i = 0; i < len; i++) {
html.Element child = _children[i];
// Don't remove children that have been reused by CrossFrameCache.
if (child.parent == rootElement) {
child.remove();
}
}
_children.clear();
_childOverdraw = false;
_cachedLastCssFont = null;
_setupInitialTransform();
}
/// Checks whether this [BitmapCanvas] can still be recycled and reused.
///
/// See also:
///
/// * [PersistedPicture._applyBitmapPaint] which uses this method to
/// decide whether to reuse this canvas or not.
/// * [PersistedPicture._recycleCanvas] which also uses this method
/// for the same reason.
bool isReusable() {
return _devicePixelRatio ==
EnginePlatformDispatcher.browserDevicePixelRatio;
}
/// Returns a "data://" URI containing a representation of the image in this
/// canvas in PNG format.
String toDataUrl() {
return _canvasPool.toDataUrl();
}
/// Sets the global paint styles to correspond to [paint].
void _setUpPaint(SurfacePaintData paint, ui.Rect? shaderBounds) {
_canvasPool.contextHandle.setUpPaint(paint, shaderBounds);
}
void _tearDownPaint() {
_canvasPool.contextHandle.tearDownPaint();
}
@override
int save() {
_canvasPool.save();
return _saveCount++;
}
void saveLayer(ui.Rect bounds, ui.Paint paint) {
save();
}
@override
void restore() {
_canvasPool.restore();
_saveCount--;
_cachedLastCssFont = null;
}
// TODO(yjbanov): not sure what this is attempting to do, but it is probably
// wrong because some clips and transforms are expressed using
// HTML DOM elements.
void restoreToCount(int count) {
assert(_saveCount >= count);
final int restores = _saveCount - count;
for (int i = 0; i < restores; i++) {
_canvasPool.restore();
}
_saveCount = count;
}
@override
void translate(double dx, double dy) {
_canvasPool.translate(dx, dy);
}
@override
void scale(double sx, double sy) {
_canvasPool.scale(sx, sy);
}
@override
void rotate(double radians) {
_canvasPool.rotate(radians);
}
@override
void skew(double sx, double sy) {
_canvasPool.skew(sx, sy);
}
@override
void transform(Float32List matrix4) {
TransformKind transformKind = transformKindOf(matrix4);
if (transformKind == TransformKind.complex) {
_contains3dTransform = true;
}
_canvasPool.transform(matrix4);
}
@override
void clipRect(ui.Rect rect, ui.ClipOp op) {
if (op == ui.ClipOp.difference) {
// Create 2 rectangles inside each other that represents
// clip area difference using even-odd fill rule.
final SurfacePath path = new SurfacePath();
path.fillType = ui.PathFillType.evenOdd;
path.addRect(ui.Rect.fromLTWH(0, 0, _bounds.width, _bounds.height));
path.addRect(rect);
_canvasPool.clipPath(path);
} else {
_canvasPool.clipRect(rect);
}
}
@override
void clipRRect(ui.RRect rrect) {
_canvasPool.clipRRect(rrect);
}
@override
void clipPath(ui.Path path) {
_canvasPool.clipPath(path);
}
/// Whether drawing operation should use DOM node instead of Canvas.
///
/// - Perspective transforms are not supported by canvas and require
/// DOM to render correctly.
/// - Pictures typically have large rect/rounded rectangles as background
/// prefer DOM if canvas has not been allocated yet.
///
bool _useDomForRenderingFill(SurfacePaintData paint) =>
_renderStrategy.isInsideShaderMask ||
(_preserveImageData == false && _contains3dTransform) ||
(_childOverdraw &&
_canvasPool._canvas == null &&
paint.maskFilter == null &&
paint.shader == null &&
paint.style != ui.PaintingStyle.stroke);
/// Same as [_useDomForRenderingFill] but allows stroke as well.
///
/// DOM canvas is generated for simple strokes using borders.
bool _useDomForRenderingFillAndStroke(SurfacePaintData paint) =>
_renderStrategy.isInsideShaderMask ||
(_preserveImageData == false && _contains3dTransform) ||
((_childOverdraw ||
_renderStrategy.hasImageElements ||
_renderStrategy.hasParagraphs) &&
_canvasPool._canvas == null &&
paint.maskFilter == null &&
paint.shader == null);
@override
void drawColor(ui.Color color, ui.BlendMode blendMode) {
final SurfacePaintData paintData = SurfacePaintData()
..color = color
..blendMode = blendMode;
if (_useDomForRenderingFill(paintData)) {
drawRect(_computeScreenBounds(_canvasPool._currentTransform), paintData);
} else {
_canvasPool.drawColor(color, blendMode);
}
}
@override
void drawLine(ui.Offset p1, ui.Offset p2, SurfacePaintData paint) {
if (_useDomForRenderingFill(paint)) {
final SurfacePath path = SurfacePath()
..moveTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy);
drawPath(path, paint);
} else {
ui.Rect? shaderBounds =
(paint.shader != null) ? ui.Rect.fromPoints(p1, p2) : null;
_setUpPaint(paint, shaderBounds);
_canvasPool.strokeLine(p1, p2);
_tearDownPaint();
}
}
@override
void drawPaint(SurfacePaintData paint) {
if (_useDomForRenderingFill(paint)) {
drawRect(_computeScreenBounds(_canvasPool._currentTransform), paint);
} else {
ui.Rect? shaderBounds =
(paint.shader != null) ? _computePictureBounds() : null;
_setUpPaint(paint, shaderBounds);
_canvasPool.fill();
_tearDownPaint();
}
}
@override
void drawRect(ui.Rect rect, SurfacePaintData paint) {
if (_useDomForRenderingFillAndStroke(paint)) {
html.HtmlElement element = _buildDrawRectElement(
rect, paint, 'draw-rect', _canvasPool._currentTransform);
_drawElement(
element,
ui.Offset(
math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)),
paint);
} else {
_setUpPaint(paint, rect);
_canvasPool.drawRect(rect, paint.style);
_tearDownPaint();
}
}
/// Inserts a dom element at [offset] creating stack of divs for clipping
/// if required.
void _drawElement(
html.Element element, ui.Offset offset, SurfacePaintData paint) {
if (_canvasPool.isClipped) {
final List<html.Element> clipElements = _clipContent(
_canvasPool._clipStack!,
element,
ui.Offset.zero,
transformWithOffset(_canvasPool._currentTransform, offset));
for (html.Element clipElement in clipElements) {
rootElement.append(clipElement);
_children.add(clipElement);
}
} else {
rootElement.append(element);
_children.add(element);
}
ui.BlendMode? blendMode = paint.blendMode;
if (blendMode != null) {
element.style.mixBlendMode = _stringForBlendMode(blendMode) ?? '';
}
// Switch to preferring DOM from now on, and close the current canvas.
_closeCurrentCanvas();
}
@override
void drawRRect(ui.RRect rrect, SurfacePaintData paint) {
final ui.Rect rect = rrect.outerRect;
if (_useDomForRenderingFillAndStroke(paint)) {
html.HtmlElement element = _buildDrawRectElement(
rect, paint, 'draw-rrect', _canvasPool._currentTransform);
_applyRRectBorderRadius(element.style, rrect);
_drawElement(
element,
ui.Offset(
math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)),
paint);
} else {
_setUpPaint(paint, rrect.outerRect);
_canvasPool.drawRRect(rrect, paint.style);
_tearDownPaint();
}
}
@override
void drawDRRect(ui.RRect outer, ui.RRect inner, SurfacePaintData paint) {
_setUpPaint(paint, outer.outerRect);
_canvasPool.drawDRRect(outer, inner, paint.style);
_tearDownPaint();
}
@override
void drawOval(ui.Rect rect, SurfacePaintData paint) {
if (_useDomForRenderingFill(paint)) {
html.HtmlElement element = _buildDrawRectElement(
rect, paint, 'draw-oval', _canvasPool._currentTransform);
_drawElement(
element,
ui.Offset(
math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)),
paint);
element.style.borderRadius =
'${(rect.width / 2.0)}px / ${(rect.height / 2.0)}px';
} else {
_setUpPaint(paint, rect);
_canvasPool.drawOval(rect, paint.style);
_tearDownPaint();
}
}
@override
void drawCircle(ui.Offset c, double radius, SurfacePaintData paint) {
ui.Rect rect = ui.Rect.fromCircle(center: c, radius: radius);
if (_useDomForRenderingFillAndStroke(paint)) {
html.HtmlElement element = _buildDrawRectElement(
rect, paint, 'draw-circle', _canvasPool._currentTransform);
_drawElement(
element,
ui.Offset(
math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)),
paint);
element.style.borderRadius = '50%';
} else {
_setUpPaint(
paint,
paint.shader != null
? ui.Rect.fromCircle(center: c, radius: radius)
: null);
_canvasPool.drawCircle(c, radius, paint.style);
_tearDownPaint();
}
}
@override
void drawPath(ui.Path path, SurfacePaintData paint) {
if (_useDomForRenderingFill(paint)) {
final Matrix4 transform = _canvasPool._currentTransform;
final SurfacePath surfacePath = path as SurfacePath;
final ui.Rect? pathAsLine = surfacePath.toStraightLine();
if (pathAsLine != null) {
final ui.Rect rect = (pathAsLine.top == pathAsLine.bottom)
? ui.Rect.fromLTWH(
pathAsLine.left, pathAsLine.top, pathAsLine.width, 1)
: ui.Rect.fromLTWH(
pathAsLine.left, pathAsLine.top, 1, pathAsLine.height);
html.HtmlElement element = _buildDrawRectElement(
rect, paint, 'draw-rect', _canvasPool._currentTransform);
_drawElement(
element,
ui.Offset(math.min(rect.left, rect.right),
math.min(rect.top, rect.bottom)),
paint);
return;
}
final ui.Rect? pathAsRect = surfacePath.toRect();
if (pathAsRect != null) {
drawRect(pathAsRect, paint);
return;
}
final ui.RRect? pathAsRRect = surfacePath.toRoundedRect();
if (pathAsRRect != null) {
drawRRect(pathAsRRect, paint);
return;
}
final ui.Rect pathBounds = surfacePath.getBounds();
html.Element svgElm = _pathToSvgElement(
surfacePath, paint, '${pathBounds.right}', '${pathBounds.bottom}');
if (!_canvasPool.isClipped) {
html.CssStyleDeclaration style = svgElm.style;
style.position = 'absolute';
if (!transform.isIdentity()) {
style
..transform = matrix4ToCssTransform(transform)
..transformOrigin = '0 0 0';
}
}
_applyFilter(svgElm, paint);
_drawElement(svgElm, ui.Offset(0, 0), paint);
} else {
_setUpPaint(paint, paint.shader != null ? path.getBounds() : null);
if (paint.style == null && paint.strokeWidth != null) {
_canvasPool.drawPath(path, ui.PaintingStyle.stroke);
} else {
_canvasPool.drawPath(path, paint.style);
}
_tearDownPaint();
}
}
void _applyFilter(html.Element element, SurfacePaintData paint) {
if (paint.maskFilter != null) {
final bool isStroke = paint.style == ui.PaintingStyle.stroke;
String cssColor = paint.color == null
? '#000000'
: colorToCssString(
paint.color)!;
final double sigma = paint.maskFilter!.webOnlySigma;
if (browserEngine == BrowserEngine.webkit && !isStroke) {
// A bug in webkit leaves artifacts when this element is animated
// with filter: blur, we use boxShadow instead.
element.style.boxShadow = '0px 0px ${sigma * 2.0}px $cssColor';
} else {
element.style.filter = 'blur(${sigma}px)';
}
}
}
@override
void drawShadow(ui.Path path, ui.Color color, double elevation,
bool transparentOccluder) {
_canvasPool.drawShadow(path, color, elevation, transparentOccluder);
}
@override
void drawImage(ui.Image image, ui.Offset p, SurfacePaintData paint) {
final html.HtmlElement imageElement = _drawImage(image, p, paint);
if (paint.colorFilter != null) {
_applyTargetSize(
imageElement, image.width.toDouble(), image.height.toDouble());
}
_closeCurrentCanvas();
}
html.ImageElement _reuseOrCreateImage(HtmlImage htmlImage) {
final String cacheKey = htmlImage.imgElement.src!;
if (_elementCache != null) {
html.ImageElement? imageElement =
_elementCache!.reuse(cacheKey) as html.ImageElement?;
if (imageElement != null) {
return imageElement;
}
}
// Can't reuse, create new instance.
html.ImageElement newImageElement = htmlImage.cloneImageElement();
if (_elementCache != null) {
_elementCache!.cache(cacheKey, newImageElement, _onEvictElement);
}
return newImageElement;
}
static void _onEvictElement(html.HtmlElement element) {
element.remove();
}
html.HtmlElement _drawImage(
ui.Image image, ui.Offset p, SurfacePaintData paint) {
final HtmlImage htmlImage = image as HtmlImage;
final ui.BlendMode? blendMode = paint.blendMode;
final EngineColorFilter? colorFilter =
paint.colorFilter as EngineColorFilter?;
html.HtmlElement imgElement;
if (colorFilter is _CkBlendModeColorFilter) {
switch (colorFilter.blendMode) {
case ui.BlendMode.colorBurn:
case ui.BlendMode.colorDodge:
case ui.BlendMode.hue:
case ui.BlendMode.modulate:
case ui.BlendMode.overlay:
case ui.BlendMode.plus:
case ui.BlendMode.srcIn:
case ui.BlendMode.srcATop:
case ui.BlendMode.srcOut:
case ui.BlendMode.saturation:
case ui.BlendMode.color:
case ui.BlendMode.luminosity:
case ui.BlendMode.xor:
imgElement = _createImageElementWithSvgFilter(
image, colorFilter.color, colorFilter.blendMode, paint);
break;
default:
imgElement = _createBackgroundImageWithBlend(
image, colorFilter.color, colorFilter.blendMode, paint);
break;
}
} else {
// No Blending, create an image by cloning original loaded image.
imgElement = _reuseOrCreateImage(htmlImage);
}
imgElement.style.mixBlendMode = _stringForBlendMode(blendMode) ?? '';
if (_canvasPool.isClipped) {
// Reset width/height since they may have been previously set.
imgElement.style..removeProperty('width')..removeProperty('height');
final List<html.Element> clipElements = _clipContent(
_canvasPool._clipStack!, imgElement, p, _canvasPool.currentTransform);
for (html.Element clipElement in clipElements) {
rootElement.append(clipElement);
_children.add(clipElement);
}
} else {
final String cssTransform = float64ListToCssTransform(
transformWithOffset(_canvasPool.currentTransform, p).storage);
imgElement.style
..transformOrigin = '0 0 0'
..transform = cssTransform
// Reset width/height since they may have been previously set.
..removeProperty('width')
..removeProperty('height');
rootElement.append(imgElement);
_children.add(imgElement);
}
return imgElement;
}
@override
void drawImageRect(
ui.Image image, ui.Rect src, ui.Rect dst, SurfacePaintData paint) {
final bool requiresClipping = src.left != 0 ||
src.top != 0 ||
src.width != image.width ||
src.height != image.height;
// If source and destination sizes are identical, we can skip the longer
// code path that sets the size of the element and clips.
//
// If there is a color filter set however, we maybe using background-image
// to render therefore we have to explicitely set width/height of the
// element for blending to work with background-color.
if (dst.width == image.width &&
dst.height == image.height &&
!requiresClipping &&
paint.colorFilter == null) {
_drawImage(image, dst.topLeft, paint);
} else {
if (requiresClipping) {
save();
clipRect(dst, ui.ClipOp.intersect);
}
double targetLeft = dst.left;
double targetTop = dst.top;
if (requiresClipping) {
if (src.width != image.width) {
double leftMargin = -src.left * (dst.width / src.width);
targetLeft += leftMargin;
}
if (src.height != image.height) {
double topMargin = -src.top * (dst.height / src.height);
targetTop += topMargin;
}
}
final html.Element imgElement =
_drawImage(image, ui.Offset(targetLeft, targetTop), paint);
// To scale set width / height on destination image.
// For clipping we need to scale according to
// clipped-width/full image width and shift it according to left/top of
// source rectangle.
double targetWidth = dst.width;
double targetHeight = dst.height;
if (requiresClipping) {
targetWidth *= image.width / src.width;
targetHeight *= image.height / src.height;
}
_applyTargetSize(
imgElement as html.HtmlElement, targetWidth, targetHeight);
if (requiresClipping) {
restore();
}
}
_closeCurrentCanvas();
}
void _applyTargetSize(
html.HtmlElement imageElement, double targetWidth, double targetHeight) {
final html.CssStyleDeclaration imageStyle = imageElement.style;
final String widthPx = '${targetWidth.toStringAsFixed(2)}px';
final String heightPx = '${targetHeight.toStringAsFixed(2)}px';
imageStyle
// left,top are set to 0 (although position is absolute) because
// Chrome will glitch if you leave them out, reproducable with
// canvas_image_blend_test on row 6, MacOS / Chrome 81.04.
..left = "0px"
..top = "0px"
..width = widthPx
..height = heightPx;
if (imageElement is! html.ImageElement) {
imageElement.style.backgroundSize = '$widthPx $heightPx';
}
}
// Creates a Div element to render an image using background-image css
// attribute to be able to use background blend mode(s) when possible.
//
// Example: <div style="
// position:absolute;
// background-image:url(....);
// background-blend-mode:"darken"
// background-color: #RRGGBB">
//
// Special cases:
// For clear,dstOut it generates a blank element.
// For src,srcOver it only sets background-color attribute.
// For dst,dstIn , it only sets source not background color.
html.HtmlElement _createBackgroundImageWithBlend(
HtmlImage image,
ui.Color? filterColor,
ui.BlendMode colorFilterBlendMode,
SurfacePaintData paint) {
// When blending with color we can't use an image element.
// Instead use a div element with background image, color and
// background blend mode.
final html.HtmlElement imgElement = html.DivElement();
final html.CssStyleDeclaration style = imgElement.style;
switch (colorFilterBlendMode) {
case ui.BlendMode.clear:
case ui.BlendMode.dstOut:
style.position = 'absolute';
break;
case ui.BlendMode.src:
case ui.BlendMode.srcOver:
style
..position = 'absolute'
..backgroundColor = colorToCssString(filterColor);
break;
case ui.BlendMode.dst:
case ui.BlendMode.dstIn:
style
..position = 'absolute'
..backgroundImage = "url('${image.imgElement.src}')";
break;
default:
style
..position = 'absolute'
..backgroundImage = "url('${image.imgElement.src}')"
..backgroundBlendMode =
_stringForBlendMode(colorFilterBlendMode) ?? ''
..backgroundColor = colorToCssString(filterColor);
break;
}
return imgElement;
}
// Creates an image element and an svg filter to apply on the element.
html.HtmlElement _createImageElementWithSvgFilter(
HtmlImage image,
ui.Color? filterColor,
ui.BlendMode colorFilterBlendMode,
SurfacePaintData paint) {
// For srcIn blendMode, we use an svg filter to apply to image element.
String? svgFilter =
svgFilterFromBlendMode(filterColor, colorFilterBlendMode);
final html.Element filterElement =
html.Element.html(svgFilter, treeSanitizer: _NullTreeSanitizer());
rootElement.append(filterElement);
_children.add(filterElement);
final html.HtmlElement imgElement = _reuseOrCreateImage(image);
imgElement.style.filter = 'url(#_fcf${_filterIdCounter})';
if (colorFilterBlendMode == ui.BlendMode.saturation) {
imgElement.style.backgroundColor = colorToCssString(filterColor);
}
return imgElement;
}
// Should be called when we add new html elements into rootElement so that
// paint order is preserved.
//
// For example if we draw a path and then a paragraph and image:
// - rootElement
// |--- <canvas>
// |--- <p>
// |--- <img>
// Any drawing operations after these tags should allocate a new canvas,
// instead of drawing into earlier canvas.
void _closeCurrentCanvas() {
_canvasPool.closeCurrentCanvas();
_childOverdraw = true;
_cachedLastCssFont = null;
}
void setCssFont(String cssFont) {
if (cssFont != _cachedLastCssFont) {
html.CanvasRenderingContext2D ctx = _canvasPool.context;
ctx.font = cssFont;
_cachedLastCssFont = cssFont;
}
}
/// Measures the given [text] and returns a [html.TextMetrics] object that
/// contains information about the measurement.
///
/// The text is measured using the font set by the most recent call to
/// [setCssFont].
html.TextMetrics measureText(String text) {
return _canvasPool.context.measureText(text);
}
/// Draws text to the canvas starting at coordinate ([x], [y]).
///
/// The text is drawn starting at coordinates ([x], [y]). It uses the current
/// font set by the most recent call to [setCssFont].
void fillText(String text, double x, double y, {List<ui.Shadow>? shadows}) {
final html.CanvasRenderingContext2D ctx = _canvasPool.context;
if (shadows != null) {
ctx.save();
for (final ui.Shadow shadow in shadows) {
ctx.shadowColor = colorToCssString(shadow.color)!;
ctx.shadowBlur = shadow.blurRadius;
ctx.shadowOffsetX = shadow.offset.dx;
ctx.shadowOffsetY = shadow.offset.dy;
ctx.fillText(text, x, y);
}
ctx.restore();
}
ctx.fillText(text, x, y);
}
@override
void drawParagraph(EngineParagraph paragraph, ui.Offset offset) {
assert(paragraph.isLaidOut);
if (paragraph.drawOnCanvas && _childOverdraw == false) {
paragraph.paint(this, offset);
return;
}
final html.Element paragraphElement =
_drawParagraphElement(paragraph, offset);
if (_canvasPool.isClipped) {
final List<html.Element> clipElements = _clipContent(
_canvasPool._clipStack!,
paragraphElement as html.HtmlElement,
offset,
_canvasPool.currentTransform);
for (html.Element clipElement in clipElements) {
rootElement.append(clipElement);
_children.add(clipElement);
}
} else {
setElementTransform(
paragraphElement,
transformWithOffset(_canvasPool.currentTransform, offset).storage,
);
rootElement.append(paragraphElement);
}
_children.add(paragraphElement);
// If there is a prior sibling such as img prevent left/top shift.
paragraphElement.style
..left = "0px"
..top = "0px";
_closeCurrentCanvas();
}
/// Draws vertices on a gl context.
///
/// If both colors and textures is specified in paint data,
/// for [BlendMode.source] we skip colors and use textures,
/// for [BlendMode.dst] we only use colors and ignore textures.
/// We also skip paint shader when no texture is specified.
///
/// If no colors or textures are specified, stroke hairlines with
/// [Paint.color].
///
/// If colors is specified, convert colors to premultiplied (alpha) colors
/// and use a SkTriColorShader to render.
@override
void drawVertices(SurfaceVertices vertices, ui.BlendMode blendMode,
SurfacePaintData paint) {
// TODO(flutter_web): Implement shaders for [Paint.shader] and
// blendMode. https://github.com/flutter/flutter/issues/40096
// Move rendering to OffscreenCanvas so that transform is preserved
// as well.
assert(paint.shader == null,
'Linear/Radial/SweepGradient and ImageShader not supported yet');
final Int32List? colors = vertices._colors;
final ui.VertexMode mode = vertices._mode;
html.CanvasRenderingContext2D? ctx = _canvasPool.context;
if (colors == null && paint.style != ui.PaintingStyle.fill) {
final Float32List positions = mode == ui.VertexMode.triangles
? vertices._positions
: _convertVertexPositions(mode, vertices._positions);
// Draw hairline for vertices if no vertex colors are specified.
save();
final ui.Color color = paint.color ?? ui.Color(0xFF000000);
_canvasPool.contextHandle
..fillStyle = null
..strokeStyle = colorToCssString(color);
_glRenderer!.drawHairline(ctx, positions);
restore();
return;
}
_glRenderer!.drawVertices(ctx, _widthInBitmapPixels, _heightInBitmapPixels,
_canvasPool.currentTransform, vertices, blendMode, paint);
}
/// Stores paint data used by [drawPoints]. We cannot use the original paint
/// data object because painting style is determined by [ui.PointMode] and
/// not by [SurfacePointData.style].
static SurfacePaintData _drawPointsPaint = SurfacePaintData()
..strokeCap = ui.StrokeCap.round
..strokeJoin = ui.StrokeJoin.round
..blendMode = ui.BlendMode.srcOver;
@override
void drawPoints(
ui.PointMode pointMode, Float32List points, SurfacePaintData paint) {
if (pointMode == ui.PointMode.points) {
_drawPointsPaint.style = ui.PaintingStyle.stroke;
} else {
_drawPointsPaint.style = ui.PaintingStyle.fill;
}
_drawPointsPaint.color = paint.color ?? const ui.Color(0xFF000000);
_drawPointsPaint.maskFilter = paint.maskFilter;
final double dpr = ui.window.devicePixelRatio;
// Use hairline (device pixel when strokeWidth is not specified).
final double strokeWidth =
paint.strokeWidth == null ? 1.0 / dpr : paint.strokeWidth!;
_drawPointsPaint.strokeWidth = strokeWidth;
_setUpPaint(_drawPointsPaint, null);
// Draw point using circle with half radius.
_canvasPool.drawPoints(pointMode, points, strokeWidth / 2.0);
_tearDownPaint();
}
@override
void endOfPaint() {
_canvasPool.endOfPaint();
_elementCache?.commitFrame();
// Wrap all elements in translate3d (workaround for webkit paint order bug).
if (_contains3dTransform && browserEngine == BrowserEngine.webkit) {
for (html.Element element in rootElement.children) {
html.DivElement paintOrderElement = html.DivElement()
..style.transform = 'translate3d(0,0,0)';
paintOrderElement.append(element);
rootElement.append(paintOrderElement);
_children.add(paintOrderElement);
}
}
if (rootElement.firstChild is html.HtmlElement &&
(rootElement.firstChild as html.HtmlElement).tagName.toLowerCase() ==
'canvas') {
(rootElement.firstChild as html.HtmlElement).style.zIndex = '-1';
}
}
/// Computes paint bounds given [targetTransform] to completely cover window
/// viewport.
ui.Rect _computeScreenBounds(Matrix4 targetTransform) {
final Matrix4 inverted = targetTransform.clone()..invert();
final double dpr = ui.window.devicePixelRatio;
final double width = ui.window.physicalSize.width * dpr;
final double height = ui.window.physicalSize.height * dpr;
Vector3 topLeft = inverted.perspectiveTransform(Vector3(0, 0, 0));
Vector3 topRight = inverted.perspectiveTransform(Vector3(width, 0, 0));
Vector3 bottomRight =
inverted.perspectiveTransform(Vector3(width, height, 0));
Vector3 bottomLeft = inverted.perspectiveTransform(Vector3(0, height, 0));
return ui.Rect.fromLTRB(
math.min(topLeft.x,
math.min(topRight.x, math.min(bottomRight.x, bottomLeft.x))),
math.min(topLeft.y,
math.min(topRight.y, math.min(bottomRight.y, bottomLeft.y))),
math.max(topLeft.x,
math.max(topRight.x, math.max(bottomRight.x, bottomLeft.x))),
math.max(topLeft.y,
math.max(topRight.y, math.max(bottomRight.y, bottomLeft.y))),
);
}
/// Computes paint bounds to completely cover picture.
ui.Rect _computePictureBounds() {
return ui.Rect.fromLTRB(0, 0, _bounds.width, _bounds.height);
}
}
String? _stringForBlendMode(ui.BlendMode? blendMode) {
if (blendMode == null) {
return null;
}
switch (blendMode) {
case ui.BlendMode.srcOver:
return 'source-over';
case ui.BlendMode.srcIn:
return 'source-in';
case ui.BlendMode.srcOut:
return 'source-out';
case ui.BlendMode.srcATop:
return 'source-atop';
case ui.BlendMode.dstOver:
return 'destination-over';
case ui.BlendMode.dstIn:
return 'destination-in';
case ui.BlendMode.dstOut:
return 'destination-out';
case ui.BlendMode.dstATop:
return 'destination-atop';
case ui.BlendMode.plus:
return 'lighten';
case ui.BlendMode.src:
return 'copy';
case ui.BlendMode.xor:
return 'xor';
case ui.BlendMode.multiply:
// Falling back to multiply, ignoring alpha channel.
// TODO(flutter_web): only used for debug, find better fallback for web.
case ui.BlendMode.modulate:
return 'multiply';
case ui.BlendMode.screen:
return 'screen';
case ui.BlendMode.overlay:
return 'overlay';
case ui.BlendMode.darken:
return 'darken';
case ui.BlendMode.lighten:
return 'lighten';
case ui.BlendMode.colorDodge:
return 'color-dodge';
case ui.BlendMode.colorBurn:
return 'color-burn';
case ui.BlendMode.hardLight:
return 'hard-light';
case ui.BlendMode.softLight:
return 'soft-light';
case ui.BlendMode.difference:
return 'difference';
case ui.BlendMode.exclusion:
return 'exclusion';
case ui.BlendMode.hue:
return 'hue';
case ui.BlendMode.saturation:
return 'saturation';
case ui.BlendMode.color:
return 'color';
case ui.BlendMode.luminosity:
return 'luminosity';
default:
throw UnimplementedError(
'Flutter Web does not support the blend mode: $blendMode');
}
}
String? _stringForStrokeCap(ui.StrokeCap? strokeCap) {
if (strokeCap == null) {
return null;
}
switch (strokeCap) {
case ui.StrokeCap.butt:
return 'butt';
case ui.StrokeCap.round:
return 'round';
case ui.StrokeCap.square:
default:
return 'square';
}
}
String _stringForStrokeJoin(ui.StrokeJoin strokeJoin) {
assert(strokeJoin != null); // ignore: unnecessary_null_comparison
switch (strokeJoin) {
case ui.StrokeJoin.round:
return 'round';
case ui.StrokeJoin.bevel:
return 'bevel';
case ui.StrokeJoin.miter:
default:
return 'miter';
}
}
/// Clips the content element against a stack of clip operations and returns
/// root of a tree that contains content node.
///
/// The stack of clipping rectangles generate an element that either uses
/// overflow:hidden with bounds to clip child or sets a clip-path to clip
/// it's contents. The clipping rectangles are nested and returned together
/// with a list of svg elements that provide clip-paths.
List<html.Element> _clipContent(List<_SaveClipEntry> clipStack,
html.Element content, ui.Offset offset, Matrix4 currentTransform) {
html.Element? root, curElement;
final List<html.Element> clipDefs = <html.Element>[];
final int len = clipStack.length;
for (int clipIndex = 0; clipIndex < len; clipIndex++) {
final _SaveClipEntry entry = clipStack[clipIndex];
final html.HtmlElement newElement = html.DivElement();
newElement.style.position = 'absolute';
applyWebkitClipFix(newElement);
if (root == null) {
root = newElement;
} else {
domRenderer.append(curElement!, newElement);
}
curElement = newElement;
final ui.Rect? rect = entry.rect;
Matrix4 newClipTransform = entry.currentTransform;
final TransformKind transformKind =
transformKindOf(newClipTransform.storage);
bool requiresTransformStyle = transformKind == TransformKind.complex;
if (rect != null) {
final double clipOffsetX = rect.left;
final double clipOffsetY = rect.top;
newClipTransform = newClipTransform.clone()
..translate(clipOffsetX, clipOffsetY);
curElement.style
..overflow = 'hidden'
..width = '${rect.right - clipOffsetX}px'
..height = '${rect.bottom - clipOffsetY}px';
setElementTransform(curElement, newClipTransform.storage);
} else if (entry.rrect != null) {
final ui.RRect roundRect = entry.rrect!;
final String borderRadius =
'${roundRect.tlRadiusX}px ${roundRect.trRadiusX}px '
'${roundRect.brRadiusX}px ${roundRect.blRadiusX}px';
final double clipOffsetX = roundRect.left;
final double clipOffsetY = roundRect.top;
newClipTransform = newClipTransform.clone()
..translate(clipOffsetX, clipOffsetY);
curElement.style
..borderRadius = borderRadius
..overflow = 'hidden'
..width = '${roundRect.right - clipOffsetX}px'
..height = '${roundRect.bottom - clipOffsetY}px';
setElementTransform(curElement, newClipTransform.storage);
} else if (entry.path != null) {
curElement.style
..transform = matrix4ToCssTransform(newClipTransform)
..transformOrigin = '0 0 0';
String svgClipPath =
createSvgClipDef(curElement as html.HtmlElement, entry.path!);
final html.Element clipElement =
html.Element.html(svgClipPath, treeSanitizer: _NullTreeSanitizer());
clipDefs.add(clipElement);
}
// Reverse the transform of the clipping element so children can use
// effective transform to render.
// TODO(flutter_web): When we have more than a single clip element,
// reduce number of div nodes by merging (multiplying transforms).
final html.Element reverseTransformDiv = html.DivElement();
reverseTransformDiv.style.position = 'absolute';
setElementTransform(
reverseTransformDiv,
(newClipTransform.clone()..invert()).storage,
);
if (requiresTransformStyle) {
// Instead of flattening matrix3d, preserve so it can be reversed.
curElement.style.transformStyle = 'preserve-3d';
reverseTransformDiv.style.transformStyle = 'preserve-3d';
}
curElement.append(reverseTransformDiv);
curElement = reverseTransformDiv;
}
root!.style.position = 'absolute';
domRenderer.append(curElement!, content);
setElementTransform(
content,
transformWithOffset(currentTransform, offset).storage,
);
return <html.Element>[root]..addAll(clipDefs);
}
/// Converts a [maskFilter] to the value to be used on a `<canvas>`.
///
/// Only supported in non-WebKit browsers.
String _maskFilterToCanvasFilter(ui.MaskFilter? maskFilter) {
assert(
browserEngine != BrowserEngine.webkit,
'WebKit (Safari) does not support `filter` canvas property.',
);
if (maskFilter != null) {
// Multiply by device-pixel ratio because the canvas' pixel width and height
// are larger than its CSS width and height by device-pixel ratio.
return 'blur(${maskFilter.webOnlySigma * window.devicePixelRatio}px)';
} else {
return 'none';
}
}