Rich text editor for Flutter
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

347 lines
11 KiB

import 'dart:async';
import 'package:flutter/widgets.dart';
import '../utils/platform.dart';
import 'box.dart';
/// Style properties of editing cursor.
class CursorStyle {
const CursorStyle({
required this.color,
required this.backgroundColor,
this.width = 1.0,
this.height,
this.radius,
this.offset,
this.opacityAnimates = false,
this.paintAboveText = false,
});
/// The color to use when painting the cursor.
final Color color;
/// The color to use when painting the background cursor aligned with the text
/// while rendering the floating cursor.
final Color backgroundColor;
/// How thick the cursor will be.
///
/// The cursor will draw under the text. The cursor width will extend
/// to the right of the boundary between characters for left-to-right text
/// and to the left for right-to-left text. This corresponds to extending
/// downstream relative to the selected position. Negative values may be used
/// to reverse this behavior.
final double width;
/// How tall the cursor will be.
///
/// By default, the cursor height is set to the preferred line height of the
/// text.
final double? height;
/// How rounded the corners of the cursor should be.
///
/// By default, the cursor has no radius.
final Radius? radius;
/// The offset that is used, in pixels, when painting the cursor on screen.
///
/// By default, the cursor position should be set to an offset of
/// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android
/// platforms. The origin from where the offset is applied to is the arbitrary
/// location where the cursor ends up being rendered from by default.
final Offset? offset;
/// Whether the cursor will animate from fully transparent to fully opaque
/// during each cursor blink.
///
/// By default, the cursor opacity will animate on iOS platforms and will not
/// animate on Android platforms.
final bool opacityAnimates;
/// If the cursor should be painted on top of the text or underneath it.
///
/// By default, the cursor should be painted on top for iOS platforms and
/// underneath for Android platforms.
final bool paintAboveText;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CursorStyle &&
runtimeType == other.runtimeType &&
color == other.color &&
backgroundColor == other.backgroundColor &&
width == other.width &&
height == other.height &&
radius == other.radius &&
offset == other.offset &&
opacityAnimates == other.opacityAnimates &&
paintAboveText == other.paintAboveText;
@override
int get hashCode =>
color.hashCode ^
backgroundColor.hashCode ^
width.hashCode ^
height.hashCode ^
radius.hashCode ^
offset.hashCode ^
opacityAnimates.hashCode ^
paintAboveText.hashCode;
}
/// Controls the cursor of an editable widget.
///
/// This class is a [ChangeNotifier] and allows to listen for updates on the
/// cursor [style].
class CursorCont extends ChangeNotifier {
CursorCont({
required this.show,
required CursorStyle style,
required TickerProvider tickerProvider,
}) : _style = style,
blink = ValueNotifier(false),
color = ValueNotifier(style.color) {
_blinkOpacityController =
AnimationController(vsync: tickerProvider, duration: _fadeDuration);
_blinkOpacityController.addListener(_onColorTick);
}
// The time it takes for the cursor to fade from fully opaque to fully
// transparent and vice versa. A full cursor blink, from transparent to opaque
// to transparent, is twice this duration.
static const Duration _blinkHalfPeriod = Duration(milliseconds: 500);
// The time the cursor is static in opacity before animating to become
// transparent.
static const Duration _blinkWaitForStart = Duration(milliseconds: 150);
// This value is an eyeball estimation of the time it takes for the iOS cursor
// to ease in and out.
static const Duration _fadeDuration = Duration(milliseconds: 250);
final ValueNotifier<bool> show;
final ValueNotifier<Color> color;
final ValueNotifier<bool> blink;
late final AnimationController _blinkOpacityController;
Timer? _cursorTimer;
bool _targetCursorVisibility = false;
final ValueNotifier<TextPosition?> _floatingCursorTextPosition =
ValueNotifier(null);
ValueNotifier<TextPosition?> get floatingCursorTextPosition =>
_floatingCursorTextPosition;
void setFloatingCursorTextPosition(TextPosition? position) =>
_floatingCursorTextPosition.value = position;
bool get isFloatingCursorActive => floatingCursorTextPosition.value != null;
CursorStyle _style;
CursorStyle get style => _style;
set style(CursorStyle value) {
if (_style == value) return;
_style = value;
notifyListeners();
}
/// True when this [CursorCont] instance has been disposed.
///
/// A safety mechanism to prevent the value of a disposed controller from
/// getting set.
bool _isDisposed = false;
@override
void dispose() {
_blinkOpacityController.removeListener(_onColorTick);
stopCursorTimer();
_isDisposed = true;
_blinkOpacityController.dispose();
show.dispose();
blink.dispose();
color.dispose();
assert(_cursorTimer == null);
super.dispose();
}
void _cursorTick(Timer timer) {
_targetCursorVisibility = !_targetCursorVisibility;
final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0;
if (style.opacityAnimates) {
// If we want to show the cursor, we will animate the opacity to the value
// of 1.0, and likewise if we want to make it disappear, to 0.0. An easing
// curve is used for the animation to mimic the aesthetics of the native
// iOS cursor.
//
// These values and curves have been obtained through eyeballing, so are
// likely not exactly the same as the values for native iOS.
_blinkOpacityController.animateTo(targetOpacity, curve: Curves.easeOut);
} else {
_blinkOpacityController.value = targetOpacity;
}
}
void _waitForStart(Timer timer) {
_cursorTimer?.cancel();
_cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick);
}
void startCursorTimer() {
if (_isDisposed) {
return;
}
_targetCursorVisibility = true;
_blinkOpacityController.value = 1.0;
if (style.opacityAnimates) {
_cursorTimer = Timer.periodic(_blinkWaitForStart, _waitForStart);
} else {
_cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick);
}
}
void stopCursorTimer({bool resetCharTicks = true}) {
_cursorTimer?.cancel();
_cursorTimer = null;
_targetCursorVisibility = false;
_blinkOpacityController.value = 0.0;
if (style.opacityAnimates) {
_blinkOpacityController
..stop()
..value = 0.0;
}
}
void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) {
if (show.value &&
_cursorTimer == null &&
hasFocus &&
selection.isCollapsed) {
startCursorTimer();
} else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) {
stopCursorTimer();
}
}
void _onColorTick() {
color.value = _style.color.withOpacity(_blinkOpacityController.value);
blink.value = show.value && _blinkOpacityController.value > 0;
}
}
/// Paints the editing cursor.
class CursorPainter {
CursorPainter({
required this.editable,
required this.style,
required this.prototype,
required this.color,
required this.devicePixelRatio,
});
final RenderContentProxyBox? editable;
final CursorStyle style;
final Rect prototype;
final Color color;
final double devicePixelRatio;
/// Paints cursor on [canvas] at specified [position].
/// [offset] is global top left (x, y) of text line
/// [position] is relative (x) in text line
void paint(
Canvas canvas,
Offset offset,
TextPosition position,
bool lineHasEmbed,
) {
// relative (x, y) to global offset
var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype);
if (lineHasEmbed && relativeCaretOffset == Offset.zero) {
relativeCaretOffset = editable!.getOffsetForCaret(
TextPosition(
offset: position.offset - 1, affinity: position.affinity),
prototype);
// Hardcoded 6 as estimate of the width of a character
relativeCaretOffset =
Offset(relativeCaretOffset.dx + 6, relativeCaretOffset.dy);
}
final caretOffset = relativeCaretOffset + offset;
var caretRect = prototype.shift(caretOffset);
if (style.offset != null) {
caretRect = caretRect.shift(style.offset!);
}
if (caretRect.left < 0.0) {
// For iOS the cursor may get clipped by the scroll view when
// it's located at a beginning of a line. We ensure that this
// does not happen here. This may result in the cursor being painted
// closer to the character on the right, but it's arguably better
// then painting clipped cursor (or even cursor completely hidden).
caretRect = caretRect.shift(Offset(-caretRect.left, 0));
}
final caretHeight = editable!.getFullHeightForCaret(position);
if (caretHeight != null) {
if (isAppleOS()) {
// Center the caret vertically along the text.
caretRect = Rect.fromLTWH(
caretRect.left,
caretRect.top + (caretHeight - caretRect.height) / 2,
caretRect.width,
caretRect.height,
);
} else {
// Override the height to take the full height of the glyph at the
// TextPosition when not on iOS. iOS has special handling that
// creates a taller caret.
caretRect = Rect.fromLTWH(
caretRect.left,
caretRect.top - 2.0,
caretRect.width,
caretHeight,
);
}
}
final pixelPerfectOffset = _getPixelPerfectCursorOffset(caretRect);
if (!pixelPerfectOffset.isFinite) {
return;
}
caretRect = caretRect.shift(pixelPerfectOffset);
final paint = Paint()..color = color;
if (style.radius == null) {
canvas.drawRect(caretRect, paint);
} else {
final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!);
canvas.drawRRect(caretRRect, paint);
}
}
Offset _getPixelPerfectCursorOffset(
Rect caretRect,
) {
final caretPosition = editable!.localToGlobal(caretRect.topLeft);
final pixelMultiple = 1.0 / devicePixelRatio;
final pixelPerfectOffsetX = caretPosition.dx.isFinite
? (caretPosition.dx / pixelMultiple).round() * pixelMultiple -
caretPosition.dx
: caretPosition.dx;
final pixelPerfectOffsetY = caretPosition.dy.isFinite
? (caretPosition.dy / pixelMultiple).round() * pixelMultiple -
caretPosition.dy
: caretPosition.dy;
return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY);
}
}