import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:tuple/tuple.dart'; import '../model/quill_delta.dart'; import '../util/delta_diff.dart'; import '../model/document/attribute.dart'; import '../model/document/document.dart'; import '../model/document/style.dart'; import '../model/document/node/embed.dart'; class EditorController extends ChangeNotifier { EditorController({ required this.document, required this.selection, }); factory EditorController.basic() { return EditorController( document: Document(), selection: const TextSelection.collapsed(offset: 0), ); } final Document document; TextSelection selection; Style toggledStyle = Style(); // item1: Document state before [change]. // item2: Change delta applied to the document. // item3: The source of this change. Stream> get changes => document.changes; TextEditingValue get plainTextEditingValue => TextEditingValue( text: document.toPlainText(), selection: selection, ); Style getSelectionStyle() => document.collectStyle(selection.start, selection.end - selection.start) ..mergeAll(toggledStyle); bool get hasUndo => document.hasUndo; bool get hasRedo => document.hasRedo; void undo() { final action = document.undo(); if (action.item1) { _handleHistoryChange(action.item2); } } void redo() { final action = document.redo(); if (action.item1) { _handleHistoryChange(action.item2); } } @override void dispose() { document.close(); super.dispose(); } void updateSelection(TextSelection textSelection, ChangeSource source) { _updateSelection(textSelection, source); notifyListeners(); } void formatSelection(Attribute? attribute) { formatText(selection.start, selection.end - selection.start, attribute); } void formatText(int index, int length, Attribute? attribute) { if (length == 0 && attribute!.isInline && attribute.key != Attribute.link.key) { toggledStyle = toggledStyle.put(attribute); } final change = document.format(index, length, attribute); final adjustedSelection = selection.copyWith( baseOffset: change.transformPosition(selection.baseOffset), extentOffset: change.transformPosition(selection.extentOffset), ); if (selection != adjustedSelection) { _updateSelection(adjustedSelection, ChangeSource.LOCAL); } notifyListeners(); } void replaceText( int index, int length, Object? data, TextSelection? textSelection) { assert(data is String || data is Embeddable); Delta? delta; if (length > 0 || data is! String || data.isNotEmpty) { delta = document.replace(index, length, data); var shouldRetainDelta = toggledStyle.isNotEmpty && delta.isNotEmpty && delta.length <= 2 && delta.last.isInsert; if (shouldRetainDelta && toggledStyle.isNotEmpty && delta.length == 2 && delta.last.data == '\n') { // if all attributes are inline, shouldRetainDelta should be false final anyAttributeNotInline = toggledStyle.values.any((attr) => !attr.isInline); shouldRetainDelta &= anyAttributeNotInline; } if (shouldRetainDelta) { final retainDelta = Delta() ..retain(index) ..retain( data is String ? data.length : 1, toggledStyle.toJson(), ); document.compose(retainDelta, ChangeSource.LOCAL); } } toggledStyle = Style(); if (textSelection != null) { if (delta == null || delta.isEmpty) { _updateSelection(textSelection, ChangeSource.LOCAL); } else { final user = Delta() ..retain(index) ..insert(data) ..delete(length); final positionDelta = getPositionDelta(user, delta); _updateSelection( textSelection.copyWith( baseOffset: textSelection.baseOffset + positionDelta, extentOffset: textSelection.extentOffset + positionDelta, ), ChangeSource.LOCAL); } } notifyListeners(); } void compose(Delta delta, TextSelection textSelection, ChangeSource source) { if (delta.isNotEmpty) { document.compose(delta, source); } textSelection = selection.copyWith( baseOffset: delta.transformPosition(selection.baseOffset, force: false), extentOffset: delta.transformPosition(selection.extentOffset, force: false), ); if (selection != textSelection) { _updateSelection(textSelection, source); } notifyListeners(); } /* --------------------------------- Helper --------------------------------- */ void _handleHistoryChange(int? length) { if (length != 0) { updateSelection( TextSelection.collapsed(offset: selection.baseOffset + length!), ChangeSource.LOCAL, ); } else { // no need to move cursor notifyListeners(); } } void _updateSelection(TextSelection textSelection, ChangeSource source) { selection = textSelection; final end = document.length - 1; selection = selection.copyWith( baseOffset: math.min(selection.baseOffset, end), extentOffset: math.min(selection.extentOffset, end), ); } }