Merge branch 'master' into dev

pull/257/head
zhaoce 4 years ago
commit 0f02221be8
  1. 2
      .github/ISSUE_TEMPLATE/issue-template.md
  2. 6
      CHANGELOG.md
  3. 31
      lib/models/documents/document.dart
  4. 8
      lib/models/rules/rule.dart
  5. 4
      lib/widgets/controller.dart
  6. 32
      lib/widgets/editor.dart
  7. 564
      lib/widgets/raw_editor.dart
  8. 354
      lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart
  9. 40
      lib/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart
  10. 200
      lib/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart
  11. 344
      lib/widgets/simple_viewer.dart
  12. 70
      lib/widgets/text_block.dart
  13. 2
      pubspec.yaml

@ -13,4 +13,4 @@ My issue is about [Desktop]
I have tried running `example` directory successfully before creating an issue here.
Please note that we are using stable channel. If you are using beta or master channel, those are not supported.
Please note that we are using stable channel on branch master. If you are using beta or master channel, use branch dev.

@ -1,3 +1,9 @@
## [1.3.0]
* Support flutter 2.2.0.
## [1.2.2]
* Checkbox supports tapping.
## [1.2.1]
* Indented position not holding while editing.

@ -40,6 +40,10 @@ class Document {
final Rules _rules = Rules.getInstance();
void setCustomRules(List<Rule> customRules) {
_rules.setCustomRules(customRules);
}
final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer =
StreamController.broadcast();
@ -47,7 +51,7 @@ class Document {
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream;
Delta insert(int index, Object? data, {int replaceLength = 0}) {
Delta insert(int index, Object? data, {int replaceLength = 0, bool autoAppendNewlineAfterImage = true}) {
assert(index >= 0);
assert(data is String || data is Embeddable);
if (data is Embeddable) {
@ -58,7 +62,7 @@ class Document {
final delta = _rules.apply(RuleType.INSERT, this, index,
data: data, len: replaceLength);
compose(delta, ChangeSource.LOCAL);
compose(delta, ChangeSource.LOCAL, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
return delta;
}
@ -71,7 +75,7 @@ class Document {
return delta;
}
Delta replace(int index, int len, Object? data) {
Delta replace(int index, int len, Object? data, {bool autoAppendNewlineAfterImage = true}) {
assert(index >= 0);
assert(data is String || data is Embeddable);
@ -84,7 +88,8 @@ class Document {
// We have to insert before applying delete rules
// Otherwise delete would be operating on stale document snapshot.
if (dataIsNotEmpty) {
delta = insert(index, data, replaceLength: len);
delta = insert(index, data, replaceLength: len,
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
}
if (len > 0) {
@ -124,13 +129,13 @@ class Document {
return block.queryChild(res.offset, true);
}
void compose(Delta delta, ChangeSource changeSource) {
void compose(Delta delta, ChangeSource changeSource, {bool autoAppendNewlineAfterImage = true}) {
assert(!_observer.isClosed);
delta.trim();
assert(delta.isNotEmpty);
var offset = 0;
delta = _transform(delta);
delta = _transform(delta, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
final originalDelta = toDelta();
for (final op in delta.toList()) {
final style =
@ -174,22 +179,28 @@ class Document {
bool get hasRedo => _history.hasRedo;
static Delta _transform(Delta delta) {
static Delta _transform(Delta delta, {bool autoAppendNewlineAfterImage = true}) {
final res = Delta();
final ops = delta.toList();
for (var i = 0; i < ops.length; i++) {
final op = ops[i];
res.push(op);
_handleImageInsert(i, ops, op, res);
if (autoAppendNewlineAfterImage) {
_autoAppendNewlineAfterImage(i, ops, op, res);
}
}
return res;
}
static void _handleImageInsert(
static void _autoAppendNewlineAfterImage(
int i, List<Operation> ops, Operation op, Delta res) {
final nextOpIsImage =
i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String;
if (nextOpIsImage && !(op.data as String).endsWith('\n')) {
if (nextOpIsImage &&
op.data is String &&
(op.data as String).isNotEmpty &&
!(op.data as String).endsWith('\n'))
{
res.push(Operation.insert('\n'));
}
// Currently embed is equivalent to image and hence `is! String`

@ -28,6 +28,8 @@ abstract class Rule {
class Rules {
Rules(this._rules);
List<Rule> _customRules = [];
final List<Rule> _rules;
static final Rules _instance = Rules([
const FormatLinkAtCaretPositionRule(),
@ -49,10 +51,14 @@ class Rules {
static Rules getInstance() => _instance;
void setCustomRules(List<Rule> customRules) {
_customRules = customRules;
}
Delta apply(RuleType ruleType, Document document, int index,
{int? len, Object? data, Attribute? attribute}) {
final delta = document.toDelta();
for (final rule in _rules) {
for (final rule in _customRules + _rules) {
if (rule.type != ruleType) {
continue;
}

@ -92,12 +92,12 @@ class QuillController extends ChangeNotifier {
void replaceText(
int index, int len, Object? data, TextSelection? textSelection,
{bool ignoreFocus = false}) {
{bool ignoreFocus = false, bool autoAppendNewlineAfterImage = true}) {
assert(data is String || data is Embeddable);
Delta? delta;
if (len > 0 || data is! String || data.isNotEmpty) {
delta = document.replace(index, len, data);
delta = document.replace(index, len, data, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
var shouldRetainDelta = toggledStyle.isNotEmpty &&
delta.isNotEmpty &&
delta.length <= 2 &&

@ -393,8 +393,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
final segmentResult = line.queryChild(result.offset, false);
if (segmentResult.node == null) {
if (line.length == 1) {
// tapping when no text yet on this line
_flipListCheckbox(pos, line, segmentResult);
getEditor()!.widget.controller.updateSelection(
TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL);
return true;
@ -434,37 +432,9 @@ class _QuillEditorSelectionGestureDetectorBuilder
),
);
}
return false;
}
if (_flipListCheckbox(pos, line, segmentResult)) {
return true;
}
return false;
}
bool _flipListCheckbox(
TextPosition pos, Line line, container_node.ChildQuery segmentResult) {
if (getEditor()!.widget.readOnly ||
!line.style.containsKey(Attribute.list.key) ||
segmentResult.offset != 0) {
return false;
}
// segmentResult.offset == 0 means tap at the beginning of the TextLine
final String? listVal = line.style.attributes[Attribute.list.key]!.value;
if (listVal == Attribute.unchecked.value) {
getEditor()!
.widget
.controller
.formatText(pos.offset, 0, Attribute.checked);
} else if (listVal == Attribute.checked.value) {
getEditor()!
.widget
.controller
.formatText(pos.offset, 0, Attribute.unchecked);
}
getEditor()!.widget.controller.updateSelection(
TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL);
return true;
return false;
}
Future<void> _launchUrl(String url) async {

@ -15,7 +15,6 @@ import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/block.dart';
import '../models/documents/nodes/line.dart';
import '../utils/diff_delta.dart';
import 'controller.dart';
import 'cursor.dart';
import 'default_styles.dart';
@ -23,6 +22,9 @@ import 'delegate.dart';
import 'editor.dart';
import 'keyboard_listener.dart';
import 'proxy.dart';
import 'raw_editor/raw_editor_state_keyboard_mixin.dart';
import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart';
import 'raw_editor/raw_editor_state_text_input_client_mixin.dart';
import 'text_block.dart';
import 'text_line.dart';
import 'text_selection.dart';
@ -89,405 +91,40 @@ class RawEditor extends StatefulWidget {
final EmbedBuilder embedBuilder;
@override
State<StatefulWidget> createState() {
return RawEditorState();
}
State<StatefulWidget> createState() => RawEditorState();
}
class RawEditorState extends EditorState
with
AutomaticKeepAliveClientMixin<RawEditor>,
WidgetsBindingObserver,
TickerProviderStateMixin<RawEditor>
implements TextSelectionDelegate, TextInputClient {
TickerProviderStateMixin<RawEditor>,
RawEditorStateKeyboardMixin,
RawEditorStateTextInputClientMixin,
RawEditorStateSelectionDelegateMixin {
final GlobalKey _editorKey = GlobalKey();
final List<TextEditingValue> _sentRemoteValues = [];
TextInputConnection? _textInputConnection;
TextEditingValue? _lastKnownRemoteTextEditingValue;
int _cursorResetLocation = -1;
bool _wasSelectingVerticallyWithKeyboard = false;
EditorTextSelectionOverlay? _selectionOverlay;
FocusAttachment? _focusAttachment;
late CursorCont _cursorCont;
ScrollController? _scrollController;
// Keyboard
late KeyboardListener _keyboardListener;
KeyboardVisibilityController? _keyboardVisibilityController;
StreamSubscription<bool>? _keyboardVisibilitySubscription;
late KeyboardListener _keyboardListener;
bool _didAutoFocus = false;
bool _keyboardVisible = false;
DefaultStyles? _styles;
final ClipboardStatusNotifier? _clipboardStatus =
kIsWeb ? null : ClipboardStatusNotifier();
final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
/// Whether to create an input connection with the platform for text editing
/// or not.
///
/// Read-only input fields do not need a connection with the platform since
/// there's no need for text editing capabilities (e.g. virtual keyboard).
///
/// On the web, we always need a connection because we want some browser
/// functionalities to continue to work on read-only input fields like:
///
/// - Relevant context menu.
/// - cmd/ctrl+c shortcut to copy.
/// - cmd/ctrl+a to select all.
/// - Changing the selection using a physical keyboard.
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly;
bool get _hasFocus => widget.focusNode.hasFocus;
TextDirection get _textDirection {
final result = Directionality.of(context);
return result;
}
void handleCursorMovement(
LogicalKeyboardKey key,
bool wordModifier,
bool lineModifier,
bool shift,
) {
if (wordModifier && lineModifier) {
return;
}
final selection = widget.controller.selection;
var newSelection = widget.controller.selection;
final plainText = textEditingValue.text;
final 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) {
newSelection = _handleMovingCursorVertically(
upKey, downKey, shift, selection, newSelection, plainText);
}
if (!shift) {
newSelection =
_placeCollapsedSelection(selection, newSelection, leftKey, rightKey);
}
widget.controller.updateSelection(newSelection, ChangeSource.LOCAL);
}
TextSelection _placeCollapsedSelection(TextSelection selection,
TextSelection newSelection, bool leftKey, bool rightKey) {
var newOffset = newSelection.extentOffset;
if (!selection.isCollapsed) {
if (leftKey) {
newOffset = newSelection.baseOffset < newSelection.extentOffset
? newSelection.baseOffset
: newSelection.extentOffset;
} else if (rightKey) {
newOffset = newSelection.baseOffset > newSelection.extentOffset
? newSelection.baseOffset
: newSelection.extentOffset;
}
}
return TextSelection.fromPosition(TextPosition(offset: newOffset));
}
TextSelection _handleMovingCursorVertically(
bool upKey,
bool downKey,
bool shift,
TextSelection selection,
TextSelection newSelection,
String plainText) {
final originPosition = TextPosition(
offset: upKey ? selection.baseOffset : selection.extentOffset);
final child = getRenderEditor()!.childAtPosition(originPosition);
final localPosition = TextPosition(
offset: originPosition.offset - child.getContainer().documentOffset);
var position = upKey
? child.getPositionAbove(localPosition)
: child.getPositionBelow(localPosition);
if (position == null) {
final sibling = upKey
? getRenderEditor()!.childBefore(child)
: getRenderEditor()!.childAfter(child);
if (sibling == null) {
position = TextPosition(offset: upKey ? 0 : plainText.length - 1);
} else {
final finalOffset = Offset(
child.getOffsetForCaret(localPosition).dx,
sibling
.getOffsetForCaret(TextPosition(
offset: upKey ? sibling.getContainer().length - 1 : 0))
.dy);
final siblingPosition = sibling.getPositionForOffset(finalOffset);
position = TextPosition(
offset:
sibling.getContainer().documentOffset + siblingPosition.offset);
}
} else {
position = TextPosition(
offset: child.getContainer().documentOffset + position.offset);
}
if (position.offset == newSelection.extentOffset) {
if (downKey) {
newSelection = newSelection.copyWith(extentOffset: plainText.length);
} else if (upKey) {
newSelection = newSelection.copyWith(extentOffset: 0);
}
_wasSelectingVerticallyWithKeyboard = shift;
return newSelection;
}
if (_wasSelectingVerticallyWithKeyboard && shift) {
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation);
_wasSelectingVerticallyWithKeyboard = false;
return newSelection;
}
newSelection = newSelection.copyWith(extentOffset: position.offset);
_cursorResetLocation = newSelection.extentOffset;
return newSelection;
}
TextSelection _jumpToBeginOrEndOfWord(
TextSelection newSelection,
bool wordModifier,
bool leftKey,
bool rightKey,
String plainText,
bool lineModifier,
bool shift) {
if (wordModifier) {
if (leftKey) {
final textSelection = getRenderEditor()!.selectWordAtPosition(
TextPosition(
offset: _previousCharacter(
newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
}
final textSelection = getRenderEditor()!.selectWordAtPosition(
TextPosition(
offset:
_nextCharacter(newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
} else if (lineModifier) {
if (leftKey) {
final textSelection = getRenderEditor()!.selectLineAtPosition(
TextPosition(
offset: _previousCharacter(
newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
}
final startPoint = newSelection.extentOffset;
if (startPoint < plainText.length) {
final textSelection = getRenderEditor()!
.selectLineAtPosition(TextPosition(offset: startPoint));
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
}
return newSelection;
}
if (rightKey && newSelection.extentOffset < plainText.length) {
final nextExtent =
_nextCharacter(newSelection.extentOffset, plainText, true);
final distance = nextExtent - newSelection.extentOffset;
newSelection = newSelection.copyWith(extentOffset: nextExtent);
if (shift) {
_cursorResetLocation += distance;
}
return newSelection;
}
if (leftKey && newSelection.extentOffset > 0) {
final previousExtent =
_previousCharacter(newSelection.extentOffset, plainText, true);
final 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;
}
var count = 0;
final remain = string.characters.skipWhile((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;
}
var count = 0;
int? lastNonWhitespace;
for (final 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;
void openConnectionIfNeeded() {
if (!shouldCreateInputConnection) {
return;
}
if (!hasConnection) {
_lastKnownRemoteTextEditingValue = textEditingValue;
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
inputType: TextInputType.multiline,
readOnly: widget.readOnly,
inputAction: TextInputAction.newline,
enableSuggestions: !widget.readOnly,
keyboardAppearance: widget.keyboardAppearance,
textCapitalization: widget.textCapitalization,
),
);
_textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!);
// _sentRemoteValues.add(_lastKnownRemoteTextEditingValue);
}
_textInputConnection!.show();
}
void closeConnectionIfNeeded() {
if (!hasConnection) {
return;
}
_textInputConnection!.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
void updateRemoteValueIfNeeded() {
if (!hasConnection) {
return;
}
final actualValue = textEditingValue.copyWith(
composing: _lastKnownRemoteTextEditingValue!.composing,
);
if (actualValue == _lastKnownRemoteTextEditingValue) {
return;
}
final shouldRemember =
textEditingValue.text != _lastKnownRemoteTextEditingValue!.text;
_lastKnownRemoteTextEditingValue = actualValue;
_textInputConnection!.setEditingState(actualValue);
if (shouldRemember) {
_sentRemoteValues.add(actualValue);
}
}
// Selection overlay
@override
TextEditingValue? get currentTextEditingValue =>
_lastKnownRemoteTextEditingValue;
@override
AutofillScope? get currentAutofillScope => null;
@override
void updateEditingValue(TextEditingValue value) {
if (!shouldCreateInputConnection) {
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;
}
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!;
_lastKnownRemoteTextEditingValue = value;
final oldText = effectiveLastKnownValue.text;
final text = value.text;
final cursorPosition = value.selection.extentOffset;
final diff = getDiff(oldText, text, cursorPosition);
widget.controller.replaceText(
diff.start, diff.deleted.length, diff.inserted, value.selection);
}
@override
TextEditingValue get textEditingValue {
return getTextEditingValue();
}
@override
set textEditingValue(TextEditingValue value) {
setTextEditingValue(value);
}
EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay;
EditorTextSelectionOverlay? _selectionOverlay;
@override
void performAction(TextInputAction action) {}
ScrollController? _scrollController;
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {}
late CursorCont _cursorCont;
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
throw UnimplementedError();
}
// Focus
bool _didAutoFocus = false;
FocusAttachment? _focusAttachment;
bool get _hasFocus => widget.focusNode.hasFocus;
@override
void showAutocorrectionPromptRect(int start, int end) {
throw UnimplementedError();
}
DefaultStyles? _styles;
@override
void bringIntoView(TextPosition position) {
@ -498,16 +135,13 @@ class RawEditorState extends EditorState
});
}
@override
void connectionClosed() {
if (!hasConnection) {
return;
}
_textInputConnection!.connectionClosedReceived();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
final ClipboardStatusNotifier? _clipboardStatus =
kIsWeb ? null : ClipboardStatusNotifier();
final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
TextDirection get _textDirection => Directionality.of(context);
@override
Widget build(BuildContext context) {
@ -585,6 +219,18 @@ class RawEditorState extends EditorState
}
}
/// Updates the checkbox positioned at [offset] in document
/// by changing its attribute according to [value].
void _handleCheckboxTap(int offset, bool value) {
if (!widget.readOnly) {
if (value) {
widget.controller.formatText(offset, 0, Attribute.checked);
} else {
widget.controller.formatText(offset, 0, Attribute.unchecked);
}
}
}
List<Widget> _buildChildren(Document doc, BuildContext context) {
final result = <Widget>[];
final indentLevelCounts = <int, int>{};
@ -595,21 +241,23 @@ class RawEditorState extends EditorState
} else if (node is Block) {
final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock(
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
widget.selectionColor,
_styles,
widget.enableInteractiveSelection,
_hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
widget.embedBuilder,
_cursorCont,
indentLevelCounts);
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
widget.selectionColor,
_styles,
widget.enableInteractiveSelection,
_hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
widget.embedBuilder,
_cursorCont,
indentLevelCounts,
_handleCheckboxTap,
);
result.add(editableTextBlock);
} else {
throw StateError('Unreachable.');
@ -788,89 +436,6 @@ class RawEditorState extends EditorState
!widget.controller.selection.isCollapsed;
}
void handleDelete(bool forward) {
final selection = widget.controller.selection;
final plainText = textEditingValue.text;
var cursorPosition = selection.start;
var textBefore = selection.textBefore(plainText);
var textAfter = selection.textAfter(plainText);
if (selection.isCollapsed) {
if (!forward && textBefore.isNotEmpty) {
final characterBoundary =
_previousCharacter(textBefore.length, textBefore, true);
textBefore = textBefore.substring(0, characterBoundary);
cursorPosition = characterBoundary;
}
if (forward && textAfter.isNotEmpty && textAfter != '\n') {
final deleteCount = _nextCharacter(0, textAfter, true);
textAfter = textAfter.substring(deleteCount);
}
}
final newSelection = TextSelection.collapsed(offset: cursorPosition);
final newText = textBefore + textAfter;
final size = plainText.length - newText.length;
widget.controller.replaceText(
cursorPosition,
size,
'',
newSelection,
);
}
Future<void> handleShortcut(InputShortcut? shortcut) async {
final selection = widget.controller.selection;
final plainText = textEditingValue.text;
if (shortcut == InputShortcut.COPY) {
if (!selection.isCollapsed) {
await Clipboard.setData(
ClipboardData(text: selection.textInside(plainText)));
}
return;
}
if (shortcut == InputShortcut.CUT && !widget.readOnly) {
if (!selection.isCollapsed) {
final data = selection.textInside(plainText);
await Clipboard.setData(ClipboardData(text: data));
widget.controller.replaceText(
selection.start,
data.length,
'',
TextSelection.collapsed(offset: selection.start),
);
textEditingValue = TextEditingValue(
text:
selection.textBefore(plainText) + selection.textAfter(plainText),
selection: TextSelection.collapsed(offset: selection.start),
);
}
return;
}
if (shortcut == InputShortcut.PASTE && !widget.readOnly) {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
widget.controller.replaceText(
selection.start,
selection.end - selection.start,
data.text,
TextSelection.collapsed(offset: selection.start + data.text!.length),
);
}
return;
}
if (shortcut == InputShortcut.SELECT_ALL &&
widget.enableInteractiveSelection) {
widget.controller.updateSelection(
selection.copyWith(
baseOffset: 0,
extentOffset: textEditingValue.text.length,
),
ChangeSource.REMOTE);
return;
}
}
@override
void dispose() {
closeConnectionIfNeeded();
@ -1031,11 +596,6 @@ class RawEditorState extends EditorState
return _editorKey.currentContext!.findRenderObject() as RenderEditor?;
}
@override
EditorTextSelectionOverlay? getSelectionOverlay() {
return _selectionOverlay;
}
@override
TextEditingValue getTextEditingValue() {
return widget.controller.plainTextEditingValue;
@ -1137,12 +697,10 @@ class RawEditorState extends EditorState
@override
bool get wantKeepAlive => widget.focusNode.hasFocus;
void openOrCloseConnection() {
if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) {
openConnectionIfNeeded();
} else if (!widget.focusNode.hasFocus) {
closeConnectionIfNeeded();
}
@override
void userUpdateTextEditingValue(
TextEditingValue value, SelectionChangedCause cause) {
// TODO: implement userUpdateTextEditingValue
}
@override

@ -0,0 +1,354 @@
import 'dart:ui';
import 'package:characters/characters.dart';
import 'package:flutter/services.dart';
import '../../models/documents/document.dart';
import '../../utils/diff_delta.dart';
import '../editor.dart';
import '../keyboard_listener.dart';
mixin RawEditorStateKeyboardMixin on EditorState {
// Holds the last cursor location the user selected in the case the user tries
// to select vertically past the end or beginning of the field. If they do,
// then we need to keep the old cursor location so that we can go back to it
// if they change their minds. Only used for moving selection up and down in a
// multiline text field when selecting using the keyboard.
int _cursorResetLocation = -1;
// Whether we should reset the location of the cursor in the case the user
// tries to select vertically past the end or beginning of the field. If they
// do, then we need to keep the old cursor location so that we can go back to
// it if they change their minds. Only used for resetting selection up and
// down in a multiline text field when selecting using the keyboard.
bool _wasSelectingVerticallyWithKeyboard = false;
void handleCursorMovement(
LogicalKeyboardKey key,
bool wordModifier,
bool lineModifier,
bool shift,
) {
if (wordModifier && lineModifier) {
// If both modifiers are down, nothing happens on any of the platforms.
return;
}
final selection = widget.controller.selection;
var newSelection = widget.controller.selection;
final plainText = getTextEditingValue().text;
final 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) {
newSelection = _handleMovingCursorVertically(
upKey, downKey, shift, selection, newSelection, plainText);
}
if (!shift) {
newSelection =
_placeCollapsedSelection(selection, newSelection, leftKey, rightKey);
}
widget.controller.updateSelection(newSelection, ChangeSource.LOCAL);
}
// Handles shortcut functionality including cut, copy, paste and select all
// using control/command + (X, C, V, A).
// TODO: Add support for formatting shortcuts: Cmd+B (bold), Cmd+I (italic)
Future<void> handleShortcut(InputShortcut? shortcut) async {
final selection = widget.controller.selection;
final plainText = getTextEditingValue().text;
if (shortcut == InputShortcut.COPY) {
if (!selection.isCollapsed) {
await Clipboard.setData(
ClipboardData(text: selection.textInside(plainText)));
}
return;
}
if (shortcut == InputShortcut.CUT && !widget.readOnly) {
if (!selection.isCollapsed) {
final data = selection.textInside(plainText);
await Clipboard.setData(ClipboardData(text: data));
widget.controller.replaceText(
selection.start,
data.length,
'',
TextSelection.collapsed(offset: selection.start),
);
setTextEditingValue(TextEditingValue(
text:
selection.textBefore(plainText) + selection.textAfter(plainText),
selection: TextSelection.collapsed(offset: selection.start),
));
}
return;
}
if (shortcut == InputShortcut.PASTE && !widget.readOnly) {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
widget.controller.replaceText(
selection.start,
selection.end - selection.start,
data.text,
TextSelection.collapsed(offset: selection.start + data.text!.length),
);
}
return;
}
if (shortcut == InputShortcut.SELECT_ALL &&
widget.enableInteractiveSelection) {
widget.controller.updateSelection(
selection.copyWith(
baseOffset: 0,
extentOffset: getTextEditingValue().text.length,
),
ChangeSource.REMOTE);
return;
}
}
void handleDelete(bool forward) {
final selection = widget.controller.selection;
final plainText = getTextEditingValue().text;
var cursorPosition = selection.start;
var textBefore = selection.textBefore(plainText);
var textAfter = selection.textAfter(plainText);
if (selection.isCollapsed) {
if (!forward && textBefore.isNotEmpty) {
final characterBoundary =
_previousCharacter(textBefore.length, textBefore, true);
textBefore = textBefore.substring(0, characterBoundary);
cursorPosition = characterBoundary;
}
if (forward && textAfter.isNotEmpty && textAfter != '\n') {
final deleteCount = _nextCharacter(0, textAfter, true);
textAfter = textAfter.substring(deleteCount);
}
}
final newSelection = TextSelection.collapsed(offset: cursorPosition);
final newText = textBefore + textAfter;
final size = plainText.length - newText.length;
widget.controller.replaceText(
cursorPosition,
size,
'',
newSelection,
);
}
TextSelection _jumpToBeginOrEndOfWord(
TextSelection newSelection,
bool wordModifier,
bool leftKey,
bool rightKey,
String plainText,
bool lineModifier,
bool shift) {
if (wordModifier) {
if (leftKey) {
final textSelection = getRenderEditor()!.selectWordAtPosition(
TextPosition(
offset: _previousCharacter(
newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
}
final textSelection = getRenderEditor()!.selectWordAtPosition(
TextPosition(
offset:
_nextCharacter(newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
} else if (lineModifier) {
if (leftKey) {
final textSelection = getRenderEditor()!.selectLineAtPosition(
TextPosition(
offset: _previousCharacter(
newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
}
final startPoint = newSelection.extentOffset;
if (startPoint < plainText.length) {
final textSelection = getRenderEditor()!
.selectLineAtPosition(TextPosition(offset: startPoint));
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
}
return newSelection;
}
if (rightKey && newSelection.extentOffset < plainText.length) {
final nextExtent =
_nextCharacter(newSelection.extentOffset, plainText, true);
final distance = nextExtent - newSelection.extentOffset;
newSelection = newSelection.copyWith(extentOffset: nextExtent);
if (shift) {
_cursorResetLocation += distance;
}
return newSelection;
}
if (leftKey && newSelection.extentOffset > 0) {
final previousExtent =
_previousCharacter(newSelection.extentOffset, plainText, true);
final distance = newSelection.extentOffset - previousExtent;
newSelection = newSelection.copyWith(extentOffset: previousExtent);
if (shift) {
_cursorResetLocation -= distance;
}
return newSelection;
}
return newSelection;
}
/// Returns the index into the string of the next character boundary after the
/// given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If given
/// string.length, string.length is returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
int _nextCharacter(int index, String string, bool includeWhitespace) {
assert(index >= 0 && index <= string.length);
if (index == string.length) {
return string.length;
}
var count = 0;
final remain = string.characters.skipWhile((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;
}
/// Returns the index into the string of the previous character boundary
/// before the given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If index is 0,
/// 0 will be returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
int _previousCharacter(int index, String string, includeWhitespace) {
assert(index >= 0 && index <= string.length);
if (index == 0) {
return 0;
}
var count = 0;
int? lastNonWhitespace;
for (final 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;
}
TextSelection _handleMovingCursorVertically(
bool upKey,
bool downKey,
bool shift,
TextSelection selection,
TextSelection newSelection,
String plainText) {
final originPosition = TextPosition(
offset: upKey ? selection.baseOffset : selection.extentOffset);
final child = getRenderEditor()!.childAtPosition(originPosition);
final localPosition = TextPosition(
offset: originPosition.offset - child.getContainer().documentOffset);
var position = upKey
? child.getPositionAbove(localPosition)
: child.getPositionBelow(localPosition);
if (position == null) {
final sibling = upKey
? getRenderEditor()!.childBefore(child)
: getRenderEditor()!.childAfter(child);
if (sibling == null) {
position = TextPosition(offset: upKey ? 0 : plainText.length - 1);
} else {
final finalOffset = Offset(
child.getOffsetForCaret(localPosition).dx,
sibling
.getOffsetForCaret(TextPosition(
offset: upKey ? sibling.getContainer().length - 1 : 0))
.dy);
final siblingPosition = sibling.getPositionForOffset(finalOffset);
position = TextPosition(
offset:
sibling.getContainer().documentOffset + siblingPosition.offset);
}
} else {
position = TextPosition(
offset: child.getContainer().documentOffset + position.offset);
}
if (position.offset == newSelection.extentOffset) {
if (downKey) {
newSelection = newSelection.copyWith(extentOffset: plainText.length);
} else if (upKey) {
newSelection = newSelection.copyWith(extentOffset: 0);
}
_wasSelectingVerticallyWithKeyboard = shift;
return newSelection;
}
if (_wasSelectingVerticallyWithKeyboard && shift) {
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation);
_wasSelectingVerticallyWithKeyboard = false;
return newSelection;
}
newSelection = newSelection.copyWith(extentOffset: position.offset);
_cursorResetLocation = newSelection.extentOffset;
return newSelection;
}
TextSelection _placeCollapsedSelection(TextSelection selection,
TextSelection newSelection, bool leftKey, bool rightKey) {
var newOffset = newSelection.extentOffset;
if (!selection.isCollapsed) {
if (leftKey) {
newOffset = newSelection.baseOffset < newSelection.extentOffset
? newSelection.baseOffset
: newSelection.extentOffset;
} else if (rightKey) {
newOffset = newSelection.baseOffset > newSelection.extentOffset
? newSelection.baseOffset
: newSelection.extentOffset;
}
}
return TextSelection.fromPosition(TextPosition(offset: newOffset));
}
}

@ -0,0 +1,40 @@
import 'package:flutter/widgets.dart';
import '../editor.dart';
mixin RawEditorStateSelectionDelegateMixin on EditorState
implements TextSelectionDelegate {
@override
TextEditingValue get textEditingValue {
return getTextEditingValue();
}
@override
set textEditingValue(TextEditingValue value) {
setTextEditingValue(value);
}
@override
void bringIntoView(TextPosition position) {
// TODO: implement bringIntoView
}
@override
void hideToolbar([bool hideHandles = true]) {
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;
}

@ -0,0 +1,200 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../utils/diff_delta.dart';
import '../editor.dart';
mixin RawEditorStateTextInputClientMixin on EditorState
implements TextInputClient {
final List<TextEditingValue> _sentRemoteValues = [];
TextInputConnection? _textInputConnection;
TextEditingValue? _lastKnownRemoteTextEditingValue;
/// Whether to create an input connection with the platform for text editing
/// or not.
///
/// Read-only input fields do not need a connection with the platform since
/// there's no need for text editing capabilities (e.g. virtual keyboard).
///
/// On the web, we always need a connection because we want some browser
/// functionalities to continue to work on read-only input fields like:
///
/// - Relevant context menu.
/// - cmd/ctrl+c shortcut to copy.
/// - cmd/ctrl+a to select all.
/// - Changing the selection using a physical keyboard.
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly;
/// Returns `true` if there is open input connection.
bool get hasConnection =>
_textInputConnection != null && _textInputConnection!.attached;
/// Opens or closes input connection based on the current state of
/// [focusNode] and [value].
void openOrCloseConnection() {
if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) {
openConnectionIfNeeded();
} else if (!widget.focusNode.hasFocus) {
closeConnectionIfNeeded();
}
}
void openConnectionIfNeeded() {
if (!shouldCreateInputConnection) {
return;
}
if (!hasConnection) {
_lastKnownRemoteTextEditingValue = getTextEditingValue();
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
inputType: TextInputType.multiline,
readOnly: widget.readOnly,
inputAction: TextInputAction.newline,
enableSuggestions: !widget.readOnly,
keyboardAppearance: widget.keyboardAppearance,
textCapitalization: widget.textCapitalization,
),
);
_textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!);
// _sentRemoteValues.add(_lastKnownRemoteTextEditingValue);
}
_textInputConnection!.show();
}
/// Closes input connection if it's currently open. Otherwise does nothing.
void closeConnectionIfNeeded() {
if (!hasConnection) {
return;
}
_textInputConnection!.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
/// Updates remote value based on current state of [document] and
/// [selection].
///
/// This method may not actually send an update to native side if it thinks
/// remote value is up to date or identical.
void updateRemoteValueIfNeeded() {
if (!hasConnection) {
return;
}
// Since we don't keep track of the composing range in value provided
// by the Controller we need to add it here manually before comparing
// with the last known remote value.
// It is important to prevent excessive remote updates as it can cause
// race conditions.
final actualValue = getTextEditingValue().copyWith(
composing: _lastKnownRemoteTextEditingValue!.composing,
);
if (actualValue == _lastKnownRemoteTextEditingValue) {
return;
}
final shouldRemember =
getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text;
_lastKnownRemoteTextEditingValue = actualValue;
_textInputConnection!.setEditingState(actualValue);
if (shouldRemember) {
// Only keep track if text changed (selection changes are not relevant)
_sentRemoteValues.add(actualValue);
}
}
@override
TextEditingValue? get currentTextEditingValue =>
_lastKnownRemoteTextEditingValue;
// autofill is not needed
@override
AutofillScope? get currentAutofillScope => null;
@override
void updateEditingValue(TextEditingValue value) {
if (!shouldCreateInputConnection) {
return;
}
if (_sentRemoteValues.contains(value)) {
/// There is a race condition in Flutter text input plugin where sending
/// updates to native side too often results in broken behavior.
/// TextInputConnection.setEditingValue is an async call to native side.
/// For each such call native side _always_ sends an update which triggers
/// this method (updateEditingValue) with the same value we've sent it.
/// If multiple calls to setEditingValue happen too fast and we only
/// track the last sent value then there is no way for us to filter out
/// automatic callbacks from native side.
/// Therefore we have to keep track of all values we send to the native
/// side and when we see this same value appear here we skip it.
/// This is fragile but it's probably the only available option.
_sentRemoteValues.remove(value);
return;
}
if (_lastKnownRemoteTextEditingValue == value) {
// There is no difference between this value and the last known value.
return;
}
// Check if only composing range changed.
if (_lastKnownRemoteTextEditingValue!.text == value.text &&
_lastKnownRemoteTextEditingValue!.selection == value.selection) {
// This update only modifies composing range. Since we don't keep track
// of composing range we just need to update last known value here.
// This check fixes an issue on Android when it sends
// composing updates separately from regular changes for text and
// selection.
_lastKnownRemoteTextEditingValue = value;
return;
}
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!;
_lastKnownRemoteTextEditingValue = value;
final oldText = effectiveLastKnownValue.text;
final text = value.text;
final cursorPosition = value.selection.extentOffset;
final diff = getDiff(oldText, text, cursorPosition);
widget.controller.replaceText(
diff.start, diff.deleted.length, diff.inserted, value.selection);
}
@override
void performAction(TextInputAction action) {
// no-op
}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
// no-op
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
throw UnimplementedError();
}
@override
void showAutocorrectionPromptRect(int start, int end) {
throw UnimplementedError();
}
@override
void connectionClosed() {
if (!hasConnection) {
return;
}
_textInputConnection!.connectionClosedReceived();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
}

@ -0,0 +1,344 @@
import 'dart:convert';
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:string_validator/string_validator.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/block.dart';
import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/line.dart';
import 'controller.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'text_block.dart';
import 'text_line.dart';
class QuillSimpleViewer extends StatefulWidget {
const QuillSimpleViewer({
required this.controller,
this.customStyles,
this.truncate = false,
this.truncateScale,
this.truncateAlignment,
this.truncateHeight,
this.truncateWidth,
this.scrollBottomInset = 0,
this.padding = EdgeInsets.zero,
this.embedBuilder,
Key? key,
}) : assert(truncate ||
((truncateScale == null) &&
(truncateAlignment == null) &&
(truncateHeight == null) &&
(truncateWidth == null))),
super(key: key);
final QuillController controller;
final DefaultStyles? customStyles;
final bool truncate;
final double? truncateScale;
final Alignment? truncateAlignment;
final double? truncateHeight;
final double? truncateWidth;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
final EmbedBuilder? embedBuilder;
@override
_QuillSimpleViewerState createState() => _QuillSimpleViewerState();
}
class _QuillSimpleViewerState extends State<QuillSimpleViewer>
with SingleTickerProviderStateMixin {
late DefaultStyles _styles;
final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
late CursorCont _cursorCont;
@override
void initState() {
super.initState();
_cursorCont = CursorCont(
show: ValueNotifier<bool>(false),
style: const CursorStyle(
color: Colors.black,
backgroundColor: Colors.grey,
width: 2,
radius: Radius.zero,
offset: Offset.zero,
),
tickerProvider: this,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final parentStyles = QuillStyles.getStyles(context, true);
final defaultStyles = DefaultStyles.getInstance(context);
_styles = (parentStyles != null)
? defaultStyles.merge(parentStyles)
: defaultStyles;
if (widget.customStyles != null) {
_styles = _styles.merge(widget.customStyles!);
}
}
EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder;
Widget _defaultEmbedBuilder(BuildContext context, leaf.Embed node) {
assert(!kIsWeb, 'Please provide EmbedBuilder for Web');
switch (node.value.type) {
case 'image':
final imageUrl = _standardizeImageUrl(node.value.data);
return imageUrl.startsWith('http')
? Image.network(imageUrl)
: isBase64(imageUrl)
? Image.memory(base64.decode(imageUrl))
: Image.file(io.File(imageUrl));
default:
throw UnimplementedError(
'Embeddable type "${node.value.type}" is not supported by default embed '
'builder of QuillEditor. You must pass your own builder function to '
'embedBuilder property of QuillEditor or QuillField widgets.');
}
}
String _standardizeImageUrl(String url) {
if (url.contains('base64')) {
return url.split(',')[1];
}
return url;
}
@override
Widget build(BuildContext context) {
final _doc = widget.controller.document;
// if (_doc.isEmpty() &&
// !widget.focusNode.hasFocus &&
// widget.placeholder != null) {
// _doc = Document.fromJson(jsonDecode(
// '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]'));
// }
Widget child = CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
child: _SimpleViewer(
document: _doc,
textDirection: _textDirection,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
onSelectionChanged: _nullSelectionChanged,
scrollBottomInset: widget.scrollBottomInset,
padding: widget.padding,
children: _buildChildren(_doc, context),
),
),
);
if (widget.truncate) {
if (widget.truncateScale != null) {
child = Container(
height: widget.truncateHeight,
child: Align(
heightFactor: widget.truncateScale,
widthFactor: widget.truncateScale,
alignment: widget.truncateAlignment ?? Alignment.topLeft,
child: Container(
width: widget.truncateWidth! / widget.truncateScale!,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Transform.scale(
scale: widget.truncateScale!,
alignment:
widget.truncateAlignment ?? Alignment.topLeft,
child: child)))));
} else {
child = Container(
height: widget.truncateHeight,
width: widget.truncateWidth,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), child: child));
}
}
return QuillStyles(data: _styles, child: child);
}
List<Widget> _buildChildren(Document doc, BuildContext context) {
final result = <Widget>[];
final indentLevelCounts = <int, int>{};
for (final node in doc.root.children) {
if (node is Line) {
final editableTextLine = _getEditableTextLineFromNode(node, context);
result.add(editableTextLine);
} else if (node is Block) {
final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock(
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
Colors.black,
// selectionColor,
_styles,
false,
// enableInteractiveSelection,
false,
// hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
embedBuilder,
_cursorCont,
indentLevelCounts,
_handleCheckboxTap);
result.add(editableTextBlock);
} else {
throw StateError('Unreachable.');
}
}
return result;
}
/// Updates the checkbox positioned at [offset] in document
/// by changing its attribute according to [value].
void _handleCheckboxTap(int offset, bool value) {
// readonly - do nothing
}
TextDirection get _textDirection {
final result = Directionality.of(context);
return result;
}
EditableTextLine _getEditableTextLineFromNode(
Line node, BuildContext context) {
final textLine = TextLine(
line: node,
textDirection: _textDirection,
embedBuilder: embedBuilder,
styles: _styles,
);
final editableTextLine = EditableTextLine(
node,
null,
textLine,
0,
_getVerticalSpacingForLine(node, _styles),
_textDirection,
widget.controller.selection,
Colors.black,
//widget.selectionColor,
false,
//enableInteractiveSelection,
false,
//_hasFocus,
MediaQuery.of(context).devicePixelRatio,
_cursorCont);
return editableTextLine;
}
Tuple2<double, double> _getVerticalSpacingForLine(
Line line, DefaultStyles? defaultStyles) {
final attrs = line.style.attributes;
if (attrs.containsKey(Attribute.header.key)) {
final int? level = attrs[Attribute.header.key]!.value;
switch (level) {
case 1:
return defaultStyles!.h1!.verticalSpacing;
case 2:
return defaultStyles!.h2!.verticalSpacing;
case 3:
return defaultStyles!.h3!.verticalSpacing;
default:
throw 'Invalid level $level';
}
}
return defaultStyles!.paragraph!.verticalSpacing;
}
Tuple2<double, double> _getVerticalSpacingForBlock(
Block node, DefaultStyles? defaultStyles) {
final attrs = node.style.attributes;
if (attrs.containsKey(Attribute.blockQuote.key)) {
return defaultStyles!.quote!.verticalSpacing;
} else if (attrs.containsKey(Attribute.codeBlock.key)) {
return defaultStyles!.code!.verticalSpacing;
} else if (attrs.containsKey(Attribute.indent.key)) {
return defaultStyles!.indent!.verticalSpacing;
}
return defaultStyles!.lists!.verticalSpacing;
}
void _nullSelectionChanged(
TextSelection selection, SelectionChangedCause cause) {}
}
class _SimpleViewer extends MultiChildRenderObjectWidget {
_SimpleViewer({
required List<Widget> children,
required this.document,
required this.textDirection,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.onSelectionChanged,
required this.scrollBottomInset,
this.padding = EdgeInsets.zero,
Key? key,
}) : super(key: key, children: children);
final Document document;
final TextDirection textDirection;
final LayerLink startHandleLayerLink;
final LayerLink endHandleLayerLink;
final TextSelectionChangedHandler onSelectionChanged;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
@override
RenderEditor createRenderObject(BuildContext context) {
return RenderEditor(
null,
textDirection,
scrollBottomInset,
padding,
document,
const TextSelection(baseOffset: 0, extentOffset: 0),
false,
// hasFocus,
onSelectionChanged,
startHandleLayerLink,
endHandleLayerLink,
const EdgeInsets.fromLTRB(4, 4, 4, 5),
);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditor renderObject) {
renderObject
..document = document
..setContainer(document.root)
..textDirection = textDirection
..setStartHandleLayerLink(startHandleLayerLink)
..setEndHandleLayerLink(endHandleLayerLink)
..onSelectionChanged = onSelectionChanged
..setScrollBottomInset(scrollBottomInset)
..setPadding(padding);
}
}

@ -61,6 +61,7 @@ class EditableTextBlock extends StatelessWidget {
this.embedBuilder,
this.cursorCont,
this.indentLevelCounts,
this.onCheckboxTap,
);
final Block block;
@ -76,6 +77,7 @@ class EditableTextBlock extends StatelessWidget {
final EmbedBuilder embedBuilder;
final CursorCont cursorCont;
final Map<int, int> indentLevelCounts;
final Function(int, bool) onCheckboxTap;
@override
Widget build(BuildContext context) {
@ -161,12 +163,23 @@ class EditableTextBlock extends StatelessWidget {
if (attrs[Attribute.list.key] == Attribute.checked) {
return _Checkbox(
style: defaultStyles!.leading!.style, width: 32, isChecked: true);
key: UniqueKey(),
style: defaultStyles!.leading!.style,
width: 32,
isChecked: true,
offset: block.offset + line.offset,
onTap: onCheckboxTap,
);
}
if (attrs[Attribute.list.key] == Attribute.unchecked) {
return _Checkbox(
style: defaultStyles!.leading!.style, width: 32, isChecked: false);
key: UniqueKey(),
style: defaultStyles!.leading!.style,
width: 32,
offset: block.offset + line.offset,
onTap: onCheckboxTap,
);
}
if (attrs.containsKey(Attribute.codeBlock.key)) {
@ -685,46 +698,39 @@ class _BulletPoint extends StatelessWidget {
}
}
class _Checkbox extends StatefulWidget {
const _Checkbox({Key? key, this.style, this.width, this.isChecked})
: super(key: key);
class _Checkbox extends StatelessWidget {
const _Checkbox({
Key? key,
this.style,
this.width,
this.isChecked = false,
this.offset,
this.onTap,
}) : super(key: key);
final TextStyle? style;
final double? width;
final bool? isChecked;
final bool isChecked;
final int? offset;
final Function(int, bool)? onTap;
@override
__CheckboxState createState() => __CheckboxState();
}
class __CheckboxState extends State<_Checkbox> {
bool? isChecked;
void _onCheckboxClicked(bool? newValue) => setState(() {
isChecked = newValue;
if (isChecked!) {
// check list
} else {
// uncheck list
}
});
@override
void initState() {
super.initState();
isChecked = widget.isChecked;
void _onCheckboxClicked(bool? newValue) {
if (onTap != null && newValue != null && offset != null) {
onTap!(offset!, newValue);
}
}
@override
Widget build(BuildContext context) {
return Container(
alignment: AlignmentDirectional.topEnd,
width: widget.width,
width: width,
padding: const EdgeInsetsDirectional.only(end: 13),
child: Checkbox(
value: widget.isChecked,
onChanged: _onCheckboxClicked,
child: GestureDetector(
onLongPress: () => _onCheckboxClicked(!isChecked),
child: Checkbox(
value: isChecked,
onChanged: _onCheckboxClicked,
),
),
);
}

@ -1,6 +1,6 @@
name: flutter_quill
description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us)
version: 1.2.1
version: 1.3.0
#author: bulletjournal
homepage: https://bulletjournal.us/home/index.html
repository: https://github.com/singerdmx/flutter-quill

Loading…
Cancel
Save