blob: 7bf1b3890349ba0ca1f8cb8d502fc79cb6ede54c [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.
import 'dart:html' as html;
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:meta/meta.dart';
import 'package:ui/ui.dart' as ui;
import 'browser_detection.dart';
import 'engine_canvas.dart';
import 'html/bitmap_canvas.dart';
import 'html/painting.dart';
import 'html/path/conic.dart';
import 'html/path/path.dart';
import 'html/path/path_ref.dart';
import 'html/path/path_utils.dart';
import 'html/picture.dart';
import 'html/shaders/image_shader.dart';
import 'html/shaders/shader.dart';
import 'platform_dispatcher.dart';
import 'rrect_renderer.dart';
import 'safe_browser_api.dart';
import 'shadow.dart';
import 'util.dart';
import 'vector_math.dart';
import 'window.dart';
/// Renders picture to a CanvasElement by allocating and caching 0 or more
/// canvas(s) for [BitmapCanvas].
///
/// [BitmapCanvas] signals allocation of first canvas using allocateCanvas.
/// When a painting command such as drawImage or drawParagraph requires
/// multiple canvases for correct compositing, it calls [closeCurrentCanvas]
/// and adds the canvas(s) to [_activeCanvasList].
///
/// To make sure transformations and clips are preserved correctly when a new
/// canvas is allocated, [CanvasPool] replays the current stack on the newly
/// allocated canvas. It also maintains a [_saveContextCount] so that
/// the context stack can be reinitialized to default when reused in the future.
///
/// On a subsequent repaint, when a Picture determines that a [BitmapCanvas]
/// can be reused, [CanvasPool] will move canvas(s) from pool to reusablePool
/// to prevent reallocation.
class CanvasPool extends _SaveStackTracking {
html.CanvasRenderingContext2D? _context;
ContextStateHandle? _contextHandle;
final int _widthInBitmapPixels, _heightInBitmapPixels;
// List of canvases that have been allocated and used in this paint cycle.
List<html.CanvasElement>? _activeCanvasList;
// List of canvases available to reuse from prior paint cycle.
List<html.CanvasElement>? _reusablePool;
// Current canvas element or null if marked for lazy allocation.
html.CanvasElement? _canvas;
html.HtmlElement? _rootElement;
int _saveContextCount = 0;
final double _density;
/// Initializes canvas pool for target size and dpi.
CanvasPool(this._widthInBitmapPixels, this._heightInBitmapPixels,
this._density);
/// Initializes canvas pool to be hosted on a surface.
void mount(html.HtmlElement rootElement) {
_rootElement = rootElement;
}
/// Sets the translate transform to be applied to canvas to compensate for
/// pixel padding applied to hosting [BitmapCanvas].
///
/// Should be called during initialization after [CanvasPool] is mounted.
set initialTransform(ui.Offset transform) {
translate(transform.dx, transform.dy);
}
/// Returns [CanvasRenderingContext2D] api to draw into this canvas.
html.CanvasRenderingContext2D get context {
html.CanvasRenderingContext2D? ctx = _context;
if (ctx == null) {
_createCanvas();
ctx = _context!;
assert(_context != null);
assert(_canvas != null);
}
return ctx;
}
/// Returns [ContextStateHandle] API to efficiently update state of
/// drawing context.
ContextStateHandle get contextHandle {
if (_canvas == null) {
_createCanvas();
assert(_context != null);
assert(_canvas != null);
}
return _contextHandle!;
}
/// Returns true if a canvas is currently available for drawing.
///
/// Calling [contextHandle] or, transitively, any of the `draw*` methods while
/// this returns true will reuse the existing canvas. Otherwise, a new canvas
/// will be allocated.
///
/// Previously allocated and closed canvases (see [closeCanvas]) are not
/// considered by this getter.
bool get hasCanvas => _canvas != null;
/// Stops the currently available canvas from receiving any further drawing
/// commands.
///
/// After calling this method, a subsequent call to [contextHandle] or,
/// transitively, any of the `draw*` methods will cause a new canvas to be
/// allocated.
///
/// The closed canvas becomes an "active" canvas, that is a canvas that's used
/// to render picture content in the current frame. Active canvases may be
/// reused in other pictures if their contents are no longer needed for this
/// picture.
void closeCanvas() {
assert(_rootElement != null);
// Place clean copy of current canvas with context stack restored and paint
// reset into pool.
if (_canvas != null) {
_restoreContextSave();
_contextHandle!.reset();
_activeCanvasList ??= <html.CanvasElement>[];
_activeCanvasList!.add(_canvas!);
_canvas = null;
_context = null;
_contextHandle = null;
}
}
void _createCanvas() {
bool requiresClearRect = false;
bool reused = false;
html.CanvasElement? canvas;
if (_canvas != null) {
_canvas!.width = 0;
_canvas!.height = 0;
_canvas = null;
}
if (_reusablePool != null && _reusablePool!.isNotEmpty) {
canvas = _canvas = _reusablePool!.removeAt(0);
requiresClearRect = true;
reused = true;
} else {
// Compute the final CSS canvas size given the actual pixel count we
// allocated. This is done for the following reasons:
//
// * To satisfy the invariant: pixel size = css size * device pixel ratio.
// * To make sure that when we scale the canvas by devicePixelRatio (see
// _initializeViewport below) the pixels line up.
final double cssWidth =
_widthInBitmapPixels / EnginePlatformDispatcher.browserDevicePixelRatio;
final double cssHeight =
_heightInBitmapPixels / EnginePlatformDispatcher.browserDevicePixelRatio;
canvas = _allocCanvas(_widthInBitmapPixels, _heightInBitmapPixels);
_canvas = canvas;
// Why is this null check here, even though we just allocated a canvas element above?
//
// On iOS Safari, if you alloate too many canvases, the browser will stop allocating them
// and return null instead. If that happens, we evict canvases from the cache, giving the
// browser more memory to allocate a new canvas.
if (_canvas == null) {
// Evict BitmapCanvas(s) and retry.
reduceCanvasMemoryUsage();
canvas = _allocCanvas(_widthInBitmapPixels, _heightInBitmapPixels);
}
canvas!.style
..position = 'absolute'
..width = '${cssWidth}px'
..height = '${cssHeight}px';
}
// Before appending canvas, check if canvas is already on rootElement. This
// optimization prevents DOM .append call when a PersistentSurface is
// reused. Reading lastChild is faster than append call.
if (_rootElement!.lastChild != canvas) {
_rootElement!.append(canvas);
}
try {
if (reused) {
// If a canvas is the first element we set z-index = -1 in [BitmapCanvas]
// endOfPaint to workaround blink compositing bug. To make sure this
// does not leak when reused reset z-index.
canvas.style.removeProperty('z-index');
}
_context = canvas.context2D;
} catch (e) {
// Handle OOM.
}
if (_context == null) {
reduceCanvasMemoryUsage();
_context = canvas.context2D;
}
if (_context == null) {
/// Browser ran out of memory, try to recover current allocation
/// and bail.
_canvas?.width = 0;
_canvas?.height = 0;
_canvas = null;
return;
}
_contextHandle = ContextStateHandle(this, _context!, _density);
_initializeViewport(requiresClearRect);
_replayClipStack();
}
html.CanvasElement? _allocCanvas(int width, int height) {
// The dartdocs for `tryCreateCanvasElement` on why we don't use the
// `html.CanvasElement` constructor.
return tryCreateCanvasElement(
(width * _density).ceil(),
(height * _density).ceil(),
);
}
@override
void clear() {
super.clear();
if (_canvas != null) {
// Restore to the state where we have only applied the scaling.
final html.CanvasRenderingContext2D? ctx = _context;
if (ctx != null) {
try {
ctx.font = '';
} catch (e) {
// Firefox may explode here:
// https://bugzilla.mozilla.org/show_bug.cgi?id=941146
if (!isNsErrorFailureException(e)) {
rethrow;
}
}
}
}
reuse();
}
int _replaySingleSaveEntry(int clipDepth, Matrix4 prevTransform,
Matrix4 transform, List<SaveClipEntry>? clipStack) {
final html.CanvasRenderingContext2D ctx = context;
if (clipStack != null) {
for (final int clipCount = clipStack.length;
clipDepth < clipCount;
clipDepth++) {
final SaveClipEntry clipEntry = clipStack[clipDepth];
final Matrix4 clipTimeTransform = clipEntry.currentTransform;
// If transform for entry recording change since last element, update.
// Comparing only matrix3 elements since Canvas API restricted.
if (clipTimeTransform[0] != prevTransform[0] ||
clipTimeTransform[1] != prevTransform[1] ||
clipTimeTransform[4] != prevTransform[4] ||
clipTimeTransform[5] != prevTransform[5] ||
clipTimeTransform[12] != prevTransform[12] ||
clipTimeTransform[13] != prevTransform[13]) {
final double ratio = dpi;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.transform(
clipTimeTransform[0],
clipTimeTransform[1],
clipTimeTransform[4],
clipTimeTransform[5],
clipTimeTransform[12],
clipTimeTransform[13]);
prevTransform = clipTimeTransform;
}
if (clipEntry.rect != null) {
_clipRect(ctx, clipEntry.rect!);
} else if (clipEntry.rrect != null) {
_clipRRect(ctx, clipEntry.rrect!);
} else if (clipEntry.path != null) {
final SurfacePath path = clipEntry.path! as SurfacePath;
_runPath(ctx, path);
if (path.fillType == ui.PathFillType.nonZero) {
ctx.clip();
} else {
ctx.clip('evenodd');
}
}
}
}
// If transform was changed between last clip operation and save call,
// update.
if (transform[0] != prevTransform[0] ||
transform[1] != prevTransform[1] ||
transform[4] != prevTransform[4] ||
transform[5] != prevTransform[5] ||
transform[12] != prevTransform[12] ||
transform[13] != prevTransform[13]) {
final double ratio = dpi;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.transform(transform[0], transform[1], transform[4], transform[5],
transform[12], transform[13]);
}
return clipDepth;
}
void _replayClipStack() {
// Replay save/clip stack on this canvas now.
final html.CanvasRenderingContext2D ctx = context;
int clipDepth = 0;
Matrix4 prevTransform = Matrix4.identity();
final int len = _saveStack.length;
for (int saveStackIndex = 0; saveStackIndex < len; saveStackIndex++) {
final SaveStackEntry saveEntry = _saveStack[saveStackIndex];
clipDepth = _replaySingleSaveEntry(
clipDepth, prevTransform, saveEntry.transform, saveEntry.clipStack);
prevTransform = saveEntry.transform;
ctx.save();
++_saveContextCount;
}
_replaySingleSaveEntry(
clipDepth, prevTransform, _currentTransform, clipStack);
}
/// Marks this pool for reuse.
void reuse() {
if (_canvas != null) {
_restoreContextSave();
_contextHandle!.reset();
_activeCanvasList ??= <html.CanvasElement>[];
_activeCanvasList!.add(_canvas!);
_context = null;
_contextHandle = null;
}
_reusablePool = _activeCanvasList;
_activeCanvasList = null;
_canvas = null;
_context = null;
_contextHandle = null;
_resetTransform();
}
/// Signals to canvas pool the end of drawing commands so cached resources
/// that are reused from last instance can be cleanup.
void endOfPaint() {
if (_reusablePool != null) {
for (final html.CanvasElement e in _reusablePool!) {
if (browserEngine == BrowserEngine.webkit) {
e.width = e.height = 0;
}
e.remove();
}
_reusablePool = null;
}
_restoreContextSave();
}
void _restoreContextSave() {
while (_saveContextCount != 0) {
_context!.restore();
--_saveContextCount;
}
}
/// Configures the canvas such that its coordinate system follows the scene's
/// coordinate system, and the pixel ratio is applied such that CSS pixels are
/// translated to bitmap pixels.
void _initializeViewport(bool clearCanvas) {
final html.CanvasRenderingContext2D ctx = context;
// Save the canvas state with top-level transforms so we can undo
// any clips later when we reuse the canvas.
ctx.save();
++_saveContextCount;
// We always start with identity transform because the surrounding transform
// is applied on the DOM elements.
ctx.setTransform(1, 0, 0, 1, 0, 0);
if (clearCanvas) {
ctx.clearRect(0, 0, _widthInBitmapPixels * _density,
_heightInBitmapPixels * _density);
}
// This scale makes sure that 1 CSS pixel is translated to the correct
// number of bitmap pixels.
ctx.scale(dpi, dpi);
}
/// Returns effective dpi (browser DPI and pixel density due to transform).
double get dpi =>
EnginePlatformDispatcher.browserDevicePixelRatio * _density;
void _resetTransform() {
final html.CanvasElement? canvas = _canvas;
if (canvas != null) {
canvas.style.transformOrigin = '';
canvas.style.transform = '';
}
}
/// Returns a "data://" URI containing a representation of the image in this
/// canvas in PNG format.
String toDataUrl() => _canvas?.toDataUrl() ?? '';
@override
void save() {
super.save();
if (_canvas != null) {
context.save();
++_saveContextCount;
}
}
@override
void restore() {
super.restore();
if (_canvas != null) {
context.restore();
contextHandle.reset();
--_saveContextCount;
}
}
@override
void translate(double dx, double dy) {
super.translate(dx, dy);
if (_canvas != null) {
context.translate(dx, dy);
}
}
@override
void scale(double sx, double sy) {
super.scale(sx, sy);
if (_canvas != null) {
context.scale(sx, sy);
}
}
@override
void rotate(double radians) {
super.rotate(radians);
if (_canvas != null) {
context.rotate(radians);
}
}
@override
void skew(double sx, double sy) {
super.skew(sx, sy);
if (_canvas != null) {
context.transform(1, sy, sx, 1, 0, 0);
// | | | | | |
// | | | | | f - vertical translation
// | | | | e - horizontal translation
// | | | d - vertical scaling
// | | c - horizontal skewing
// | b - vertical skewing
// a - horizontal scaling
//
// Source: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/transform
}
}
@override
void transform(Float32List matrix4) {
super.transform(matrix4);
// Canvas2D transform API:
//
// ctx.transform(a, b, c, d, e, f);
//
// In 3x3 matrix form assuming vector representation of (x, y, 1):
//
// a c e
// b d f
// 0 0 1
//
// This translates to 4x4 matrix with vector representation of (x, y, z, 1)
// as:
//
// a c 0 e
// b d 0 f
// 0 0 1 0
// 0 0 0 1
//
// This matrix is sufficient to represent 2D rotates, translates, scales,
// and skews.
if (_canvas != null) {
context.transform(matrix4[0], matrix4[1], matrix4[4], matrix4[5],
matrix4[12], matrix4[13]);
}
}
@override
void clipRect(ui.Rect rect) {
super.clipRect(rect);
if (_canvas != null) {
_clipRect(context, rect);
}
}
void _clipRect(html.CanvasRenderingContext2D ctx, ui.Rect rect) {
ctx.beginPath();
ctx.rect(rect.left, rect.top, rect.width, rect.height);
ctx.clip();
}
@override
void clipRRect(ui.RRect rrect) {
super.clipRRect(rrect);
if (_canvas != null) {
_clipRRect(context, rrect);
}
}
void _clipRRect(html.CanvasRenderingContext2D ctx, ui.RRect rrect) {
final ui.Path path = ui.Path()..addRRect(rrect);
_runPath(ctx, path as SurfacePath);
ctx.clip();
}
@override
void clipPath(ui.Path path) {
super.clipPath(path);
if (_canvas != null) {
final html.CanvasRenderingContext2D ctx = context;
_runPath(ctx, path as SurfacePath);
if (path.fillType == ui.PathFillType.nonZero) {
ctx.clip();
} else {
ctx.clip('evenodd');
}
}
}
/// Fill a virtually infinite rect with a color and optional blendMode.
void drawColor(ui.Color color, ui.BlendMode blendMode) {
final html.CanvasRenderingContext2D ctx = context;
contextHandle.blendMode = blendMode;
contextHandle.fillStyle = colorToCssString(color);
contextHandle.strokeStyle = '';
ctx.beginPath();
// Fill a virtually infinite rect with the color.
//
// We can't use (0, 0, width, height) because the current transform can
// cause it to not fill the entire clip.
ctx.fillRect(-10000, -10000, 20000, 20000);
}
/// Fill a virtually infinite rect with the color.
void fill() {
final html.CanvasRenderingContext2D ctx = context;
ctx.beginPath();
// We can't use (0, 0, width, height) because the current transform can
// cause it to not fill the entire clip.
ctx.fillRect(-10000, -10000, 20000, 20000);
}
/// Draws a line from [p1] to [p2].
void strokeLine(ui.Offset p1, ui.Offset p2) {
final html.CanvasRenderingContext2D ctx = context;
ctx.beginPath();
final ui.Rect? shaderBounds = contextHandle._shaderBounds;
if (shaderBounds == null) {
ctx.moveTo(p1.dx, p1.dy);
ctx.lineTo(p2.dx, p2.dy);
} else {
ctx.moveTo(p1.dx - shaderBounds.left, p1.dy - shaderBounds.top);
ctx.lineTo(p2.dx - shaderBounds.left, p2.dy - shaderBounds.top);
}
ctx.stroke();
}
/// Draws a set of points with given radius, lines between points or
/// a polygon.
void drawPoints(ui.PointMode pointMode, Float32List points, double radius) {
final html.CanvasRenderingContext2D ctx = context;
final int len = points.length;
final ui.Rect? shaderBounds = contextHandle._shaderBounds;
final double offsetX = shaderBounds == null ? 0 : -shaderBounds.left;
final double offsetY = shaderBounds == null ? 0 : -shaderBounds.top;
switch (pointMode) {
case ui.PointMode.points:
for (int i = 0; i < len; i += 2) {
final double x = points[i] + offsetX;
final double y = points[i + 1] + offsetY;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2.0 * math.pi);
ctx.fill();
}
break;
case ui.PointMode.lines:
ctx.beginPath();
for (int i = 0; i < (len - 2); i += 4) {
ctx.moveTo(points[i] + offsetX, points[i + 1] + offsetY);
ctx.lineTo(points[i + 2] + offsetX, points[i + 3] + offsetY);
ctx.stroke();
}
break;
case ui.PointMode.polygon:
ctx.beginPath();
ctx.moveTo(points[0] + offsetX, points[1] + offsetY);
for (int i = 2; i < len; i += 2) {
ctx.lineTo(points[i] + offsetX, points[i + 1] + offsetY);
}
ctx.stroke();
break;
}
}
// Float buffer used for path iteration.
static Float32List _runBuffer = Float32List(PathRefIterator.kMaxBufferSize);
/// 'Runs' the given [path] by applying all of its commands to the canvas.
void _runPath(html.CanvasRenderingContext2D ctx, SurfacePath path) {
ctx.beginPath();
final Float32List p = _runBuffer;
final PathRefIterator iter = PathRefIterator(path.pathRef);
int verb = 0;
while ((verb = iter.next(p)) != SPath.kDoneVerb) {
switch (verb) {
case SPath.kMoveVerb:
ctx.moveTo(p[0], p[1]);
break;
case SPath.kLineVerb:
ctx.lineTo(p[2], p[3]);
break;
case SPath.kCubicVerb:
ctx.bezierCurveTo(p[2], p[3], p[4], p[5], p[6], p[7]);
break;
case SPath.kQuadVerb:
ctx.quadraticCurveTo(p[2], p[3], p[4], p[5]);
break;
case SPath.kConicVerb:
final double w = iter.conicWeight;
final Conic conic = Conic(p[0], p[1], p[2], p[3], p[4], p[5], w);
final List<ui.Offset> points = conic.toQuads();
final int len = points.length;
for (int i = 1; i < len; i += 2) {
final double p1x = points[i].dx;
final double p1y = points[i].dy;
final double p2x = points[i + 1].dx;
final double p2y = points[i + 1].dy;
ctx.quadraticCurveTo(p1x, p1y, p2x, p2y);
}
break;
case SPath.kCloseVerb:
ctx.closePath();
break;
default:
throw UnimplementedError('Unknown path verb $verb');
}
}
}
/// Draws a rectangle filled or stroked based on [style].
void drawRect(ui.Rect rect, ui.PaintingStyle? style) {
context.beginPath();
final ui.Rect? shaderBounds = contextHandle._shaderBounds;
if (shaderBounds == null) {
context.rect(rect.left, rect.top, rect.width, rect.height);
} else {
context.rect(rect.left - shaderBounds.left, rect.top - shaderBounds.top,
rect.width, rect.height);
}
contextHandle.paint(style);
}
/// Applies path to drawing context, preparing for fill and other operations.
///
/// WARNING: Don't refactor _runPath/_runPathWithOffset. Latency sensitive
void _runPathWithOffset(html.CanvasRenderingContext2D ctx, SurfacePath path,
double offsetX, double offsetY) {
ctx.beginPath();
final Float32List p = _runBuffer;
final PathRefIterator iter = PathRefIterator(path.pathRef);
int verb = 0;
while ((verb = iter.next(p)) != SPath.kDoneVerb) {
switch (verb) {
case SPath.kMoveVerb:
ctx.moveTo(p[0] + offsetX, p[1] + offsetY);
break;
case SPath.kLineVerb:
ctx.lineTo(p[2] + offsetX, p[3] + offsetY);
break;
case SPath.kCubicVerb:
ctx.bezierCurveTo(p[2] + offsetX, p[3] + offsetY,
p[4] + offsetX, p[5] + offsetY, p[6] + offsetX, p[7] + offsetY);
break;
case SPath.kQuadVerb:
ctx.quadraticCurveTo(p[2] + offsetX, p[3] + offsetY,
p[4] + offsetX, p[5] + offsetY);
break;
case SPath.kConicVerb:
final double w = iter.conicWeight;
final Conic conic = Conic(p[0], p[1], p[2], p[3], p[4], p[5], w);
final List<ui.Offset> points = conic.toQuads();
final int len = points.length;
for (int i = 1; i < len; i += 2) {
final double p1x = points[i].dx;
final double p1y = points[i].dy;
final double p2x = points[i + 1].dx;
final double p2y = points[i + 1].dy;
ctx.quadraticCurveTo(p1x + offsetX, p1y + offsetY,
p2x + offsetX, p2y + offsetY);
}
break;
case SPath.kCloseVerb:
ctx.closePath();
break;
default:
throw UnimplementedError('Unknown path verb $verb');
}
}
}
/// Draws a rounded rectangle filled or stroked based on [style].
void drawRRect(ui.RRect roundRect, ui.PaintingStyle? style) {
final ui.Rect? shaderBounds = contextHandle._shaderBounds;
RRectToCanvasRenderer(context).render(
shaderBounds == null ? roundRect
: roundRect.shift(ui.Offset(-shaderBounds.left, -shaderBounds.top)));
contextHandle.paint(style);
}
/// Fills or strokes the area between [outer] and [inner] rounded rectangles.
///
/// Typically used to draw a thick round border.
void drawDRRect(ui.RRect outer, ui.RRect inner, ui.PaintingStyle? style) {
final RRectRenderer renderer = RRectToCanvasRenderer(context);
final ui.Rect? shaderBounds = contextHandle._shaderBounds;
if (shaderBounds == null) {
renderer.render(outer);
renderer.render(inner, startNewPath: false, reverse: true);
} else {
final ui.Offset shift = ui.Offset(-shaderBounds.left, -shaderBounds.top);
renderer.render(outer.shift(shift));
renderer.render(inner.shift(shift), startNewPath: false, reverse: true);
}
contextHandle.paint(style);
}
/// Draws an axis-aligned oval that fills the given axis-aligned rectangle.
void drawOval(ui.Rect rect, ui.PaintingStyle? style) {
context.beginPath();
final ui.Rect? shaderBounds = contextHandle._shaderBounds;
final double cx = shaderBounds == null ? rect.center.dx :
rect.center.dx - shaderBounds.left;
final double cy = shaderBounds == null ? rect.center.dy :
rect.center.dy - shaderBounds.top;
drawEllipse(context, cx, cy, rect.width / 2,
rect.height / 2, 0, 0, 2.0 * math.pi, false);
contextHandle.paint(style);
}
/// Draws a circle centered at [c] with [radius].
void drawCircle(ui.Offset c, double radius, ui.PaintingStyle? style) {
context.beginPath();
final ui.Rect? shaderBounds = contextHandle._shaderBounds;
final double cx = shaderBounds == null ? c.dx : c.dx - shaderBounds.left;
final double cy = shaderBounds == null ? c.dy : c.dy - shaderBounds.top;
drawEllipse(context, cx, cy, radius, radius, 0, 0, 2.0 * math.pi, false);
contextHandle.paint(style);
}
/// Draws or strokes a path based on [style] and current context state.
void drawPath(ui.Path path, ui.PaintingStyle? style) {
final ui.Rect? shaderBounds = contextHandle._shaderBounds;
if (shaderBounds == null) {
_runPath(context, path as SurfacePath);
} else {
_runPathWithOffset(context, path as SurfacePath,
-shaderBounds.left, -shaderBounds.top);
}
contextHandle.paintPath(style, path.fillType);
}
/// Draws a shadow for a Path representing the given material elevation.
void drawShadow(ui.Path path, ui.Color color, double elevation,
bool transparentOccluder) {
final SurfaceShadowData? shadow = computeShadow(path.getBounds(), elevation);
if (shadow != null) {
// On April 2020 Web canvas 2D did not support shadow color alpha. So
// instead we apply alpha separately using globalAlpha, then paint a
// solid shadow.
final ui.Color shadowColor = toShadowColor(color);
final double opacity = shadowColor.alpha / 255;
final String solidColor = colorComponentsToCssString(
shadowColor.red,
shadowColor.green,
shadowColor.blue,
255,
);
context.save();
context.globalAlpha = opacity;
// TODO(hterkelsen): Shadows with transparent occluders are not supported
// on webkit since filter is unsupported.
if (transparentOccluder && browserEngine != BrowserEngine.webkit) {
// We paint shadows using a path and a mask filter instead of the
// built-in shadow* properties. This is because the color alpha of the
// paint is added to the shadow. The effect we're looking for is to just
// paint the shadow without the path itself, but if we use a non-zero
// alpha for the paint the path is painted in addition to the shadow,
// which is undesirable.
context.translate(shadow.offset.dx, shadow.offset.dy);
context.filter = maskFilterToCanvasFilter(
ui.MaskFilter.blur(ui.BlurStyle.normal, shadow.blurWidth));
context.strokeStyle = '';
context.fillStyle = solidColor;
} else {
// TODO(yjbanov): the following comment by hterkelsen makes sense, but
// somehow we lost the implementation described in it.
// Perhaps we should revisit this and actually do what
// the comment says.
// TODO(hterkelsen): We fill the path with this paint, then later we clip
// by the same path and fill it with a fully opaque color (we know
// the color is fully opaque because `transparentOccluder` is false.
// However, due to anti-aliasing of the clip, a few pixels of the
// path we are about to paint may still be visible after we fill with
// the opaque occluder. For that reason, we fill with the shadow color,
// and set the shadow color to fully opaque. This way, the visible
// pixels are less opaque and less noticeable.
context.filter = 'none';
context.strokeStyle = '';
context.fillStyle = solidColor;
context.shadowBlur = shadow.blurWidth;
context.shadowColor = solidColor;
context.shadowOffsetX = shadow.offset.dx;
context.shadowOffsetY = shadow.offset.dy;
}
_runPath(context, path as SurfacePath);
context.fill();
// This also resets globalAlpha and shadow attributes. See:
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save#Drawing_state
context.restore();
}
}
/// Disposes html canvas element(s) used by this pool when persistent surface
/// is disposed.
///
/// When this pool is reused, [clear] is called instead to be able to
/// draw using existing canvas elements.
void dispose() {
// Webkit has a threshold for the amount of canvas pixels an app can
// allocate. Even though our canvases are being garbage-collected as
// expected when we don't need them, Webkit keeps track of their sizes
// towards the threshold. Setting width and height to zero tricks Webkit
// into thinking that this canvas has a zero size so it doesn't count it
// towards the threshold.
if (browserEngine == BrowserEngine.webkit && _canvas != null) {
_canvas!.width = _canvas!.height = 0;
}
_clearActiveCanvasList();
}
void _clearActiveCanvasList() {
if (_activeCanvasList != null) {
for (final html.CanvasElement c in _activeCanvasList!) {
if (browserEngine == BrowserEngine.webkit) {
c.width = c.height = 0;
}
c.remove();
}
}
_activeCanvasList = null;
}
}
/// Optimizes applying paint parameters to html canvas.
///
/// See https://www.w3.org/TR/2dcontext/ for defaults used in this class
/// to initialize current values.
class ContextStateHandle {
/// Associated canvas element context tracked by this context state.
final html.CanvasRenderingContext2D context;
final CanvasPool _canvasPool;
/// Dpi of context.
final double density;
/// Initializes context state for a [CanvasPool].
ContextStateHandle(this._canvasPool, this.context, this.density);
ui.BlendMode? _currentBlendMode = ui.BlendMode.srcOver;
ui.StrokeCap? _currentStrokeCap = ui.StrokeCap.butt;
ui.StrokeJoin? _currentStrokeJoin = ui.StrokeJoin.miter;
// Fill style and stroke style are Object since they can have a String or
// shader object such as a gradient.
Object? _currentFillStyle;
Object? _currentStrokeStyle;
double _currentLineWidth = 1.0;
/// See [html.CanvasRenderingContext2D].
set blendMode(ui.BlendMode? blendMode) {
if (blendMode != _currentBlendMode) {
_currentBlendMode = blendMode;
context.globalCompositeOperation =
blendModeToCssMixBlendMode(blendMode) ?? 'source-over';
}
}
/// See [html.CanvasRenderingContext2D].
set strokeCap(ui.StrokeCap? strokeCap) {
strokeCap ??= ui.StrokeCap.butt;
if (strokeCap != _currentStrokeCap) {
_currentStrokeCap = strokeCap;
context.lineCap = stringForStrokeCap(strokeCap)!;
}
}
/// See [html.CanvasRenderingContext2D].
set lineWidth(double lineWidth) {
if (lineWidth != _currentLineWidth) {
_currentLineWidth = lineWidth;
context.lineWidth = lineWidth;
}
}
/// See [html.CanvasRenderingContext2D].
set strokeJoin(ui.StrokeJoin? strokeJoin) {
strokeJoin ??= ui.StrokeJoin.miter;
if (strokeJoin != _currentStrokeJoin) {
_currentStrokeJoin = strokeJoin;
context.lineJoin = stringForStrokeJoin(strokeJoin);
}
}
/// See [html.CanvasRenderingContext2D].
set fillStyle(Object? colorOrGradient) {
if (!identical(colorOrGradient, _currentFillStyle)) {
_currentFillStyle = colorOrGradient;
context.fillStyle = colorOrGradient;
}
}
/// See [html.CanvasRenderingContext2D].
set strokeStyle(Object? colorOrGradient) {
if (!identical(colorOrGradient, _currentStrokeStyle)) {
_currentStrokeStyle = colorOrGradient;
context.strokeStyle = colorOrGradient;
}
}
ui.MaskFilter? _currentFilter;
SurfacePaintData? _lastUsedPaint;
/// Currently active shader bounds.
///
/// When a paint style uses a shader that produces a pattern, the pattern
/// origin is relative to current transform. Therefore any painting operations
/// will have to reverse the transform to correctly align pattern with
/// drawing bounds.
ui.Rect? _shaderBounds;
/// The painting state.
///
/// Used to validate that the [setUpPaint] and [tearDownPaint] are called in
/// a correct sequence.
bool _debugIsPaintSetUp = false;
/// Whether to use WebKit's method of rendering [MaskFilter].
///
/// This is used in screenshot tests to test Safari codepaths.
static bool debugEmulateWebKitMaskFilter = false;
bool get _renderMaskFilterForWebkit => browserEngine == BrowserEngine.webkit || debugEmulateWebKitMaskFilter;
/// Sets paint properties on the current canvas.
///
/// [tearDownPaint] must be called after calling this method.
void setUpPaint(SurfacePaintData paint, ui.Rect? shaderBounds) {
if (assertionsEnabled) {
assert(!_debugIsPaintSetUp);
_debugIsPaintSetUp = true;
}
_lastUsedPaint = paint;
lineWidth = paint.strokeWidth ?? 1.0;
blendMode = paint.blendMode;
strokeCap = paint.strokeCap;
strokeJoin = paint.strokeJoin;
if (paint.shader != null) {
if (paint.shader is EngineGradient) {
final EngineGradient engineShader = paint.shader! as EngineGradient;
final Object paintStyle =
engineShader.createPaintStyle(_canvasPool.context, shaderBounds,
density);
fillStyle = paintStyle;
strokeStyle = paintStyle;
_shaderBounds = shaderBounds;
// Align pattern origin to destination.
context.translate(shaderBounds!.left, shaderBounds.top);
} else if (paint.shader is EngineImageShader) {
final EngineImageShader imageShader = paint.shader! as EngineImageShader;
final Object paintStyle =
imageShader.createPaintStyle(_canvasPool.context, shaderBounds,
density);
fillStyle = paintStyle;
strokeStyle = paintStyle;
if (imageShader.requiresTileOffset) {
_shaderBounds = shaderBounds;
// Align pattern origin to destination.
context.translate(shaderBounds!.left, shaderBounds.top);
}
}
} else if (paint.color != null) {
final String? colorString = colorToCssString(paint.color);
fillStyle = colorString;
strokeStyle = colorString;
} else {
fillStyle = '#000000';
strokeStyle = '#000000';
}
final ui.MaskFilter? maskFilter = paint.maskFilter;
if (!_renderMaskFilterForWebkit) {
if (_currentFilter != maskFilter) {
_currentFilter = maskFilter;
context.filter = maskFilterToCanvasFilter(maskFilter);
}
} else {
// WebKit does not support the `filter` property. Instead we apply a
// shadow to the shape of the same color as the paint and the same blur
// as the mask filter.
//
// Note that on WebKit the cached value of _currentFilter is not useful.
// Instead we destructure it into the shadow properties and cache those.
if (maskFilter != null) {
context.save();
context.shadowBlur = convertSigmaToRadius(maskFilter.webOnlySigma);
if (paint.color != null) {
// Shadow color must be fully opaque.
context.shadowColor = colorToCssString(paint.color!.withAlpha(255))!;
} else {
context.shadowColor = colorToCssString(const ui.Color(0xFF000000))!;
}
// On the web a shadow must always be painted together with the shape
// that casts it. In order to paint just the shadow, we offset the shape
// by a large enough value that it moved outside the canvas bounds, then
// offset the shadow in the opposite direction such that it lands exactly
// where the shape is.
const double kOutsideTheBoundsOffset = 50000;
context.translate(-kOutsideTheBoundsOffset, 0);
// Shadow offset is not affected by the current canvas context transform.
// We have to apply the transform ourselves. To do that we transform the
// tip of the vector from the shape to the shadow, then we transform the
// origin (0, 0). The desired shadow offset is the difference between the
// two. In vector notation, this is:
//
// transformedShadowDelta = M*shadowDelta - M*origin.
final Float32List tempVector = Float32List(2);
tempVector[0] = kOutsideTheBoundsOffset * window.devicePixelRatio;
_canvasPool.currentTransform.transform2(tempVector);
final double shadowOffsetX = tempVector[0];
final double shadowOffsetY = tempVector[1];
tempVector[0] = tempVector[1] = 0;
_canvasPool.currentTransform.transform2(tempVector);
context.shadowOffsetX = shadowOffsetX - tempVector[0];
context.shadowOffsetY = shadowOffsetY - tempVector[1];
}
}
}
/// Removes paint properties on the current canvas used by the last draw
/// command.
///
/// Not all properties are cleared. Properties that are set by all paint
/// commands prior to painting do not need to be cleared.
///
/// Must be called after calling [setUpPaint].
void tearDownPaint() {
if (assertionsEnabled) {
assert(_debugIsPaintSetUp);
_debugIsPaintSetUp = false;
}
final ui.MaskFilter? maskFilter = _lastUsedPaint?.maskFilter;
if (maskFilter != null && _renderMaskFilterForWebkit) {
// On Safari (WebKit) we use a translated shadow to emulate
// MaskFilter.blur. We use restore to undo the translation and
// shadow attributes.
context.restore();
}
if (_shaderBounds != null) {
context.translate(-_shaderBounds!.left, -_shaderBounds!.top);
_shaderBounds = null;
}
}
/// Fills or strokes the currently active path.
void paint(ui.PaintingStyle? style) {
if (style == ui.PaintingStyle.stroke) {
context.stroke();
} else {
context.fill();
}
}
/// Fills or strokes the currently active path based on fill type.
void paintPath(ui.PaintingStyle? style, ui.PathFillType pathFillType) {
if (style == ui.PaintingStyle.stroke) {
context.stroke();
} else {
if (pathFillType == ui.PathFillType.nonZero) {
context.fill();
} else {
context.fill('evenodd');
}
}
}
/// Resets drawing context state to defaults for
/// [html.CanvasRenderingContext2D].
void reset() {
context.fillStyle = '';
// Read back fillStyle/strokeStyle values from context so that input such
// as rgba(0, 0, 0, 0) is correctly compared and doesn't cause diff on
// setter.
_currentFillStyle = context.fillStyle;
context.strokeStyle = '';
_currentStrokeStyle = context.strokeStyle;
context.shadowBlur = 0;
context.shadowColor = 'none';
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.globalCompositeOperation = 'source-over';
_currentBlendMode = ui.BlendMode.srcOver;
context.lineWidth = 1.0;
_currentLineWidth = 1.0;
context.lineCap = 'butt';
_currentStrokeCap = ui.StrokeCap.butt;
context.lineJoin = 'miter';
_currentStrokeJoin = ui.StrokeJoin.miter;
_shaderBounds = null;
}
}
/// Provides save stack tracking functionality to implementations of
/// [EngineCanvas].
class _SaveStackTracking {
// !Warning: this vector should not be mutated.
static final Vector3 _unitZ = Vector3(0.0, 0.0, 1.0);
final List<SaveStackEntry> _saveStack = <SaveStackEntry>[];
/// The stack that maintains clipping operations used when text is painted
/// onto bitmap canvas but is composited as separate element.
List<SaveClipEntry>? clipStack;
/// Returns whether there are active clipping regions on the canvas.
bool get isClipped => clipStack != null;
/// Empties the save stack and the element stack, and resets the transform
/// and clip parameters.
@mustCallSuper
void clear() {
_saveStack.clear();
clipStack = null;
_currentTransform = Matrix4.identity();
}
/// The current transformation matrix.
Matrix4 get currentTransform => _currentTransform;
Matrix4 _currentTransform = Matrix4.identity();
/// Saves current clip and transform on the save stack.
@mustCallSuper
void save() {
_saveStack.add(SaveStackEntry(
transform: _currentTransform.clone(),
clipStack:
clipStack == null ? null : List<SaveClipEntry>.from(clipStack!),
));
}
/// Restores current clip and transform from the save stack.
@mustCallSuper
void restore() {
if (_saveStack.isEmpty) {
return;
}
final SaveStackEntry entry = _saveStack.removeLast();
_currentTransform = entry.transform;
clipStack = entry.clipStack;
}
/// Multiplies the [currentTransform] matrix by a translation.
@mustCallSuper
void translate(double dx, double dy) {
_currentTransform.translate(dx, dy);
}
/// Scales the [currentTransform] matrix.
@mustCallSuper
void scale(double sx, double sy) {
_currentTransform.scale(sx, sy);
}
/// Rotates the [currentTransform] matrix.
@mustCallSuper
void rotate(double radians) {
_currentTransform.rotate(_unitZ, radians);
}
/// Skews the [currentTransform] matrix.
@mustCallSuper
void skew(double sx, double sy) {
final Matrix4 skewMatrix = Matrix4.identity();
final Float32List storage = skewMatrix.storage;
storage[1] = sy;
storage[4] = sx;
_currentTransform.multiply(skewMatrix);
}
/// Multiplies the [currentTransform] matrix by another matrix.
@mustCallSuper
void transform(Float32List matrix4) {
_currentTransform.multiply(Matrix4.fromFloat32List(matrix4));
}
/// Adds a rectangle to clipping stack.
@mustCallSuper
void clipRect(ui.Rect rect) {
clipStack ??= <SaveClipEntry>[];
clipStack!.add(SaveClipEntry.rect(rect, _currentTransform.clone()));
}
/// Adds a round rectangle to clipping stack.
@mustCallSuper
void clipRRect(ui.RRect rrect) {
clipStack ??= <SaveClipEntry>[];
clipStack!.add(SaveClipEntry.rrect(rrect, _currentTransform.clone()));
}
/// Adds a path to clipping stack.
@mustCallSuper
void clipPath(ui.Path path) {
clipStack ??= <SaveClipEntry>[];
clipStack!.add(SaveClipEntry.path(path, _currentTransform.clone()));
}
}