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.

802 lines
21 KiB

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_quill/models/documents/document.dart';
import 'package:flutter_quill/utils/diff_delta.dart';
import 'package:flutter_quill/widgets/text_selection.dart';
import 'controller.dart';
import 'cursor.dart';
import 'delegate.dart';
const Set<int> WHITE_SPACE = {
0x9,
0xA,
0xB,
0xC,
0xD,
0x1C,
0x1D,
0x1E,
0x1F,
0x20,
0xA0,
0x1680,
0x2000,
0x2001,
0x2002,
0x2003,
0x2004,
0x2005,
0x2006,
0x2007,
0x2008,
0x2009,
0x200A,
0x202F,
0x205F,
0x3000
};
abstract class RenderAbstractEditor {
TextSelection selectWordAtPosition(TextPosition position);
TextSelection selectLineAtPosition(TextPosition position);
double preferredLineHeight(TextPosition position);
TextPosition getPositionForOffset(Offset offset);
List<TextSelectionPoint> getEndpointsForSelection(
TextSelection textSelection);
void handleTapDown(TapDownDetails details);
void selectWordsInRange(
Offset from,
Offset to,
SelectionChangedCause cause,
);
void selectWordEdge(SelectionChangedCause cause);
void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause);
void selectWord(SelectionChangedCause cause);
void selectPosition(SelectionChangedCause cause);
}
class QuillEditor extends StatefulWidget {
final QuillController controller;
final FocusNode focusNode;
final ScrollController scrollController;
final bool scrollable;
final EdgeInsetsGeometry padding;
final bool autoFocus;
final bool showCursor;
final bool readOnly;
final bool enableInteractiveSelection;
final double minHeight;
final double maxHeight;
final bool expands;
final TextCapitalization textCapitalization;
final Brightness keyboardAppearance;
final ScrollPhysics scrollPhysics;
final ValueChanged<String> onLaunchUrl;
4 years ago
final EmbedBuilder embedBuilder;
QuillEditor(
this.controller,
this.focusNode,
this.scrollController,
this.scrollable,
this.padding,
this.autoFocus,
this.showCursor,
this.readOnly,
this.enableInteractiveSelection,
this.minHeight,
this.maxHeight,
this.expands,
this.textCapitalization,
this.keyboardAppearance,
this.scrollPhysics,
4 years ago
this.onLaunchUrl,
this.embedBuilder)
: assert(controller != null),
assert(scrollController != null),
assert(scrollable != null),
assert(autoFocus != null),
4 years ago
assert(readOnly != null),
assert(embedBuilder != null);
@override
_QuillEditorState createState() => _QuillEditorState();
}
class _QuillEditorState extends State<QuillEditor>
implements EditorTextSelectionGestureDetectorBuilderDelegate {
final GlobalKey<EditorState> _editorKey = GlobalKey<EditorState>();
EditorTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
@override
void initState() {
super.initState();
_selectionGestureDetectorBuilder =
_QuillEditorSelectionGestureDetectorBuilder(this);
}
@override
Widget build(BuildContext context) {
ThemeData theme = Theme.of(context);
TextSelectionThemeData selectionTheme = TextSelectionTheme.of(context);
TextSelectionControls textSelectionControls;
bool paintCursorAboveText;
bool cursorOpacityAnimates;
Offset cursorOffset;
Color cursorColor;
Color selectionColor;
Radius cursorRadius;
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
textSelectionControls = materialTextSelectionControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary;
selectionColor = selectionTheme.selectionColor ??
theme.colorScheme.primary.withOpacity(0.40);
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
textSelectionControls = cupertinoTextSelectionControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor ??=
selectionTheme.cursorColor ?? cupertinoTheme.primaryColor;
selectionColor = selectionTheme.selectionColor ??
cupertinoTheme.primaryColor.withOpacity(0.40);
cursorRadius ??= const Radius.circular(2.0);
cursorOffset = Offset(
iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
default:
throw UnimplementedError();
}
return _selectionGestureDetectorBuilder.build(
HitTestBehavior.translucent,
RawEditor(
_editorKey,
widget.controller,
widget.focusNode,
widget.scrollController,
widget.scrollable,
widget.padding,
widget.readOnly,
widget.onLaunchUrl,
ToolbarOptions(
copy: true,
cut: true,
paste: true,
selectAll: true,
),
theme.platform == TargetPlatform.iOS ||
theme.platform == TargetPlatform.android,
widget.showCursor,
CursorStyle(
color: cursorColor,
backgroundColor: Colors.grey,
width: 2.0,
radius: cursorRadius,
offset: cursorOffset,
paintAboveText: paintCursorAboveText,
opacityAnimates: cursorOpacityAnimates,
),
widget.textCapitalization,
widget.maxHeight,
widget.minHeight,
widget.expands,
widget.autoFocus,
selectionColor,
textSelectionControls,
widget.keyboardAppearance,
widget.enableInteractiveSelection,
4 years ago
widget.scrollPhysics,
widget.embedBuilder),
);
}
@override
GlobalKey<EditorState> getEditableTextKey() {
return _editorKey;
}
@override
bool getForcePressEnabled() {
return false;
}
@override
bool getSelectionEnabled() {
return widget.enableInteractiveSelection;
}
}
class _QuillEditorSelectionGestureDetectorBuilder
extends EditorTextSelectionGestureDetectorBuilder {
final _QuillEditorState _state;
_QuillEditorSelectionGestureDetectorBuilder(this._state) : super(_state);
@override
onForcePressStart(ForcePressDetails details) {
super.onForcePressStart(details);
}
}
class RawEditor extends StatefulWidget {
final QuillController controller;
final FocusNode focusNode;
final ScrollController scrollController;
final bool scrollable;
final EdgeInsetsGeometry padding;
final bool readOnly;
final ValueChanged<String> onLaunchUrl;
final ToolbarOptions toolbarOptions;
final bool showSelectionHandles;
final bool showCursor;
final CursorStyle cursorStyle;
final TextCapitalization textCapitalization;
final double maxHeight;
final double minHeight;
final bool expands;
final bool autoFocus;
final Color selectionColor;
final TextSelectionControls selectionCtrls;
final Brightness keyboardAppearance;
final bool enableInteractiveSelection;
final ScrollPhysics scrollPhysics;
4 years ago
final EmbedBuilder embedBuilder;
RawEditor(
Key key,
this.controller,
this.focusNode,
this.scrollController,
this.scrollable,
this.padding,
this.readOnly,
this.onLaunchUrl,
this.toolbarOptions,
this.showSelectionHandles,
bool showCursor,
this.cursorStyle,
this.textCapitalization,
this.maxHeight,
this.minHeight,
this.expands,
this.autoFocus,
this.selectionColor,
this.selectionCtrls,
this.keyboardAppearance,
this.enableInteractiveSelection,
4 years ago
this.scrollPhysics,
this.embedBuilder)
: assert(controller != null),
assert(focusNode != null),
assert(scrollable || scrollController != null),
assert(selectionColor != null),
assert(enableInteractiveSelection != null),
assert(showSelectionHandles != null),
assert(readOnly != null),
assert(maxHeight == null || maxHeight > 0),
assert(minHeight == null || minHeight >= 0),
assert(
maxHeight == null || minHeight == null || maxHeight >= minHeight),
assert(autoFocus != null),
assert(toolbarOptions != null),
showCursor = showCursor ?? !readOnly,
4 years ago
assert(embedBuilder != null),
super(key: key);
@override
State<StatefulWidget> createState() {
return RawEditorState();
}
}
class RawEditorState extends EditorState
with
AutomaticKeepAliveClientMixin<RawEditor>,
WidgetsBindingObserver,
TickerProviderStateMixin<RawEditor>
implements TextSelectionDelegate, TextInputClient {
final GlobalKey _editorKey = GlobalKey();
final List<TextEditingValue> _sentRemoteValues = [];
TextInputConnection _textInputConnection;
TextEditingValue _lastKnownRemoteTextEditingValue;
int _cursorResetLocation = -1;
bool _wasSelectingVerticallyWithKeyboard = false;
handleCursorMovement(
LogicalKeyboardKey key,
bool wordModifier,
bool lineModifier,
bool shift,
) {
if (wordModifier && lineModifier) {
return;
}
TextSelection selection = widget.controller.selection;
assert(selection != null);
TextSelection newSelection = widget.controller.selection;
String plainText = textEditingValue.text;
bool rightKey = key == LogicalKeyboardKey.arrowRight,
leftKey = key == LogicalKeyboardKey.arrowLeft,
upKey = key == LogicalKeyboardKey.arrowUp,
downKey = key == LogicalKeyboardKey.arrowDown;
if ((rightKey || leftKey) && !(rightKey && leftKey)) {
newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier,
leftKey, rightKey, plainText, lineModifier, shift);
}
if (downKey || upKey) {}
if (!shift) {}
widget.controller.updateSelection(newSelection, ChangeSource.LOCAL);
}
TextSelection _jumpToBeginOrEndOfWord(
TextSelection newSelection,
bool wordModifier,
bool leftKey,
bool rightKey,
String plainText,
bool lineModifier,
bool shift) {
if (wordModifier) {
if (leftKey) {
TextSelection textSelection = getRenderEditor().selectWordAtPosition(
TextPosition(
offset: _previousCharacter(
newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
}
TextSelection textSelection = getRenderEditor().selectWordAtPosition(
TextPosition(
offset:
_nextCharacter(newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
} else if (lineModifier) {
if (leftKey) {
TextSelection textSelection = getRenderEditor().selectLineAtPosition(
TextPosition(
offset: _previousCharacter(
newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
}
int startPoint = newSelection.extentOffset;
if (startPoint < plainText.length) {
TextSelection textSelection = getRenderEditor()
.selectLineAtPosition(TextPosition(offset: startPoint));
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
}
return newSelection;
}
if (rightKey && newSelection.extentOffset < plainText.length) {
int nextExtent =
_nextCharacter(newSelection.extentOffset, plainText, true);
int distance = nextExtent - newSelection.extentOffset;
newSelection = newSelection.copyWith(extentOffset: nextExtent);
if (shift) {
_cursorResetLocation += distance;
}
return newSelection;
}
if (leftKey && newSelection.extentOffset > 0) {
int previousExtent =
_previousCharacter(newSelection.extentOffset, plainText, true);
int distance = newSelection.extentOffset - previousExtent;
newSelection = newSelection.copyWith(extentOffset: previousExtent);
if (shift) {
_cursorResetLocation -= distance;
}
return newSelection;
}
return newSelection;
}
int _nextCharacter(int index, String string, bool includeWhitespace) {
assert(index >= 0 && index <= string.length);
if (index == string.length) {
return string.length;
}
int count = 0;
Characters remain = string.characters.skipWhile((String currentString) {
if (count <= index) {
count += currentString.length;
return true;
}
if (includeWhitespace) {
return false;
}
return WHITE_SPACE.contains(currentString.codeUnitAt(0));
});
return string.length - remain.toString().length;
}
int _previousCharacter(int index, String string, includeWhitespace) {
assert(index >= 0 && index <= string.length);
if (index == 0) {
return 0;
}
int count = 0;
int lastNonWhitespace;
for (String currentString in string.characters) {
if (!includeWhitespace &&
!WHITE_SPACE.contains(
currentString.characters.first.toString().codeUnitAt(0))) {
lastNonWhitespace = count;
}
if (count + currentString.length >= index) {
return includeWhitespace ? count : lastNonWhitespace ?? 0;
}
count += currentString.length;
}
return 0;
}
bool get hasConnection =>
_textInputConnection != null && _textInputConnection.attached;
openConnectionIfNeeded() {
if (widget.readOnly) {
return;
}
if (!hasConnection) {
_lastKnownRemoteTextEditingValue = textEditingValue;
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
inputType: TextInputType.multiline,
readOnly: widget.readOnly,
obscureText: false,
autocorrect: false,
inputAction: TextInputAction.newline,
keyboardAppearance: widget.keyboardAppearance,
textCapitalization: widget.textCapitalization,
),
);
_textInputConnection.setEditingState(_lastKnownRemoteTextEditingValue);
_sentRemoteValues.add(_lastKnownRemoteTextEditingValue);
}
_textInputConnection.show();
}
closeConnectionIfNeeded() {
if (!hasConnection) {
return;
}
_textInputConnection.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
updateRemoteValueIfNeeded() {
if (!hasConnection) {
return;
}
TextEditingValue actualValue = textEditingValue.copyWith(
composing: _lastKnownRemoteTextEditingValue.composing,
);
if (actualValue == _lastKnownRemoteTextEditingValue) {
return;
}
bool shouldRemember =
textEditingValue.text != _lastKnownRemoteTextEditingValue.text;
_lastKnownRemoteTextEditingValue = actualValue;
_textInputConnection.setEditingState(actualValue);
if (shouldRemember) {
_sentRemoteValues.add(actualValue);
}
}
@override
TextEditingValue get currentTextEditingValue =>
_lastKnownRemoteTextEditingValue;
@override
AutofillScope get currentAutofillScope => null;
@override
void updateEditingValue(TextEditingValue value) {
if (widget.readOnly) {
return;
}
if (_sentRemoteValues.contains(value)) {
_sentRemoteValues.remove(value);
return;
}
if (_lastKnownRemoteTextEditingValue == value) {
return;
}
if (_lastKnownRemoteTextEditingValue.text == value.text &&
_lastKnownRemoteTextEditingValue.selection == value.selection) {
_lastKnownRemoteTextEditingValue = value;
return;
}
TextEditingValue effectiveLastKnownValue = _lastKnownRemoteTextEditingValue;
_lastKnownRemoteTextEditingValue = value;
String oldText = effectiveLastKnownValue.text;
String text = value.text;
int cursorPosition = value.selection.extentOffset;
Diff diff = getDiff(oldText, text, cursorPosition);
widget.controller.replaceText(
diff.start, diff.deleted.length, diff.inserted, value.selection);
}
@override
TextEditingValue get textEditingValue {
return widget.controller.plainTextEditingValue;
}
@override
set textEditingValue(TextEditingValue value) {
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL);
}
@override
void performAction(TextInputAction action) {}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
throw UnimplementedError();
}
@override
void showAutocorrectionPromptRect(int start, int end) {
throw UnimplementedError();
}
@override
void bringIntoView(TextPosition position) {
// TODO: implement bringIntoView
}
@override
void connectionClosed() {
if (!hasConnection) {
return;
}
_textInputConnection.connectionClosedReceived();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
@override
Widget build(BuildContext context) {
// TODO: implement build
throw UnimplementedError();
}
@override
RenderEditor getRenderEditor() {
// TODO: implement getRenderEditor
throw UnimplementedError();
}
@override
EditorTextSelectionOverlay getSelectionOverlay() {
// TODO: implement getSelectionOverlay
throw UnimplementedError();
}
@override
TextEditingValue getTextEditingValue() {
// TODO: implement getTextEditingValue
throw UnimplementedError();
}
@override
void hideToolbar() {
if (getSelectionOverlay()?.toolbar != null) {
getSelectionOverlay()?.hideToolbar();
}
}
@override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly;
@override
bool get copyEnabled => widget.toolbarOptions.copy;
@override
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
@override
bool get selectAllEnabled => widget.toolbarOptions.selectAll;
@override
void requestKeyboard() {
// TODO: implement requestKeyboard
}
@override
void setTextEditingValue(TextEditingValue value) {
// TODO: implement setTextEditingValue
}
@override
bool showToolbar() {
// TODO: implement showToolbar
throw UnimplementedError();
}
@override
// TODO: implement wantKeepAlive
bool get wantKeepAlive => throw UnimplementedError();
}
class RenderEditor extends RenderEditableContainerBox
implements RenderAbstractEditor {
Document document;
TextSelection selection;
bool _hasFocus = false;
setDocument(Document doc) {
assert(doc != null);
if (document == doc) {
return;
}
document = doc;
markNeedsLayout();
}
setHasFocus(bool h) {
assert(h != null);
if (_hasFocus == h) {
return;
}
_hasFocus = h;
markNeedsSemanticsUpdate();
}
setSelection(TextSelection t) {
if (selection == t) {
return;
}
selection = t;
markNeedsPaint();
}
@override
List<TextSelectionPoint> getEndpointsForSelection(
TextSelection textSelection) {
// TODO: implement getEndpointsForSelection
throw UnimplementedError();
}
@override
TextPosition getPositionForOffset(Offset offset) {
// TODO: implement getPositionForOffset
throw UnimplementedError();
}
@override
void handleTapDown(TapDownDetails details) {
// TODO: implement handleTapDown
}
@override
double preferredLineHeight(TextPosition position) {
// TODO: implement preferredLineHeight
throw UnimplementedError();
}
@override
TextSelection selectLineAtPosition(TextPosition position) {
// TODO: implement selectLineAtPosition
throw UnimplementedError();
}
@override
void selectPosition(SelectionChangedCause cause) {
// TODO: implement selectPosition
}
@override
void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause) {
// TODO: implement selectPositionAt
}
@override
void selectWord(SelectionChangedCause cause) {
// TODO: implement selectWord
}
@override
TextSelection selectWordAtPosition(TextPosition position) {
// TODO: implement selectWordAtPosition
throw UnimplementedError();
}
@override
void selectWordEdge(SelectionChangedCause cause) {
// TODO: implement selectWordEdge
}
@override
void selectWordsInRange(Offset from, Offset to, SelectionChangedCause cause) {
assert(cause != null && from != null);
// TODO: implement selectWordsInRange
}
}
class RenderEditableContainerBox extends RenderBox {
Container _container;
TextDirection _textDirection;
setContainer(Container c) {
assert(c != null);
if (_container == c) {
return;
}
_container = c;
markNeedsLayout();
}
setTextDirection(TextDirection t) {
if (_textDirection == t) {
return;
}
_textDirection = t;
}
}
abstract class EditorState extends State<RawEditor> {
TextEditingValue getTextEditingValue();
void setTextEditingValue(TextEditingValue value);
RenderEditor getRenderEditor();
EditorTextSelectionOverlay getSelectionOverlay();
bool showToolbar();
void hideToolbar();
void requestKeyboard();
}