dartlangeditorflutterflutter-appsflutter-examplesflutter-packageflutter-widgetquillquill-deltaquilljsreactquillrich-textrich-text-editorwysiwygwysiwyg-editor
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.
384 lines
11 KiB
384 lines
11 KiB
import 'dart:math' as math; |
|
|
|
import 'package:flutter/cupertino.dart'; |
|
import 'package:flutter/services.dart'; |
|
|
|
import '../models/documents/attribute.dart'; |
|
import '../models/documents/document.dart'; |
|
import '../models/documents/nodes/embeddable.dart'; |
|
import '../models/documents/nodes/leaf.dart'; |
|
import '../models/documents/style.dart'; |
|
import '../models/quill_delta.dart'; |
|
import '../models/structs/doc_change.dart'; |
|
import '../models/structs/image_url.dart'; |
|
import '../models/structs/offset_value.dart'; |
|
import '../utils/delta.dart'; |
|
|
|
typedef ReplaceTextCallback = bool Function(int index, int len, Object? data); |
|
typedef DeleteCallback = void Function(int cursorPosition, bool forward); |
|
|
|
class QuillController extends ChangeNotifier { |
|
QuillController({ |
|
required Document document, |
|
required TextSelection selection, |
|
bool keepStyleOnNewLine = false, |
|
this.onReplaceText, |
|
this.onDelete, |
|
this.onSelectionCompleted, |
|
this.onSelectionChanged, |
|
}) : _document = document, |
|
_selection = selection, |
|
_keepStyleOnNewLine = keepStyleOnNewLine; |
|
|
|
factory QuillController.basic() { |
|
return QuillController( |
|
document: Document(), |
|
selection: const TextSelection.collapsed(offset: 0), |
|
); |
|
} |
|
|
|
/// Document managed by this controller. |
|
Document _document; |
|
Document get document => _document; |
|
set document(doc) { |
|
_document = doc; |
|
|
|
// Prevent the selection from |
|
_selection = const TextSelection(baseOffset: 0, extentOffset: 0); |
|
|
|
notifyListeners(); |
|
} |
|
|
|
/// Tells whether to keep or reset the [toggledStyle] |
|
/// when user adds a new line. |
|
final bool _keepStyleOnNewLine; |
|
|
|
/// Currently selected text within the [document]. |
|
TextSelection get selection => _selection; |
|
TextSelection _selection; |
|
|
|
/// Custom [replaceText] handler |
|
/// Return false to ignore the event |
|
ReplaceTextCallback? onReplaceText; |
|
|
|
/// Custom delete handler |
|
DeleteCallback? onDelete; |
|
|
|
void Function()? onSelectionCompleted; |
|
void Function(TextSelection textSelection)? onSelectionChanged; |
|
|
|
/// Store any styles attribute that got toggled by the tap of a button |
|
/// and that has not been applied yet. |
|
/// It gets reset after each format action within the [document]. |
|
Style toggledStyle = Style(); |
|
|
|
bool ignoreFocusOnTextChange = false; |
|
|
|
/// Skip requestKeyboard being called in |
|
/// RawEditorState#_didChangeTextEditingValue |
|
bool skipRequestKeyboard = false; |
|
|
|
/// True when this [QuillController] instance has been disposed. |
|
/// |
|
/// A safety mechanism to ensure that listeners don't crash when adding, |
|
/// removing or listeners to this instance. |
|
bool _isDisposed = false; |
|
|
|
Stream<DocChange> get changes => document.changes; |
|
|
|
TextEditingValue get plainTextEditingValue => TextEditingValue( |
|
text: document.toPlainText(), |
|
selection: selection, |
|
); |
|
|
|
/// Only attributes applied to all characters within this range are |
|
/// included in the result. |
|
Style getSelectionStyle() { |
|
return document |
|
.collectStyle(selection.start, selection.end - selection.start) |
|
.mergeAll(toggledStyle); |
|
} |
|
|
|
// Increases or decreases the indent of the current selection by 1. |
|
void indentSelection(bool isIncrease) { |
|
final indent = getSelectionStyle().attributes[Attribute.indent.key]; |
|
if (indent == null) { |
|
if (isIncrease) { |
|
formatSelection(Attribute.indentL1); |
|
} |
|
return; |
|
} |
|
if (indent.value == 1 && !isIncrease) { |
|
formatSelection(Attribute.clone(Attribute.indentL1, null)); |
|
return; |
|
} |
|
if (isIncrease) { |
|
formatSelection(Attribute.getIndentLevel(indent.value + 1)); |
|
return; |
|
} |
|
formatSelection(Attribute.getIndentLevel(indent.value - 1)); |
|
} |
|
|
|
/// Returns all styles for each node within selection |
|
List<OffsetValue<Style>> getAllIndividualSelectionStyles() { |
|
final styles = document.collectAllIndividualStyles( |
|
selection.start, selection.end - selection.start); |
|
return styles; |
|
} |
|
|
|
/// Returns plain text for each node within selection |
|
String getPlainText() { |
|
final text = |
|
document.getPlainText(selection.start, selection.end - selection.start); |
|
return text; |
|
} |
|
|
|
/// Returns all styles for any character within the specified text range. |
|
List<Style> getAllSelectionStyles() { |
|
final styles = document.collectAllStyles( |
|
selection.start, selection.end - selection.start) |
|
..add(toggledStyle); |
|
return styles; |
|
} |
|
|
|
void undo() { |
|
final result = document.undo(); |
|
if (result.changed) { |
|
_handleHistoryChange(result.len); |
|
} |
|
} |
|
|
|
void _handleHistoryChange(int? len) { |
|
if (len! != 0) { |
|
// if (this.selection.extentOffset >= document.length) { |
|
// // cursor exceeds the length of document, position it in the end |
|
// updateSelection( |
|
// TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL); |
|
updateSelection( |
|
TextSelection.collapsed(offset: selection.baseOffset + len), |
|
ChangeSource.LOCAL); |
|
} else { |
|
// no need to move cursor |
|
notifyListeners(); |
|
} |
|
} |
|
|
|
void redo() { |
|
final result = document.redo(); |
|
if (result.changed) { |
|
_handleHistoryChange(result.len); |
|
} |
|
} |
|
|
|
bool get hasUndo => document.hasUndo; |
|
|
|
bool get hasRedo => document.hasRedo; |
|
|
|
/// clear editor |
|
void clear() { |
|
replaceText(0, plainTextEditingValue.text.length - 1, '', |
|
const TextSelection.collapsed(offset: 0)); |
|
} |
|
|
|
void replaceText( |
|
int index, int len, Object? data, TextSelection? textSelection, |
|
{bool ignoreFocus = false}) { |
|
assert(data is String || data is Embeddable); |
|
|
|
if (onReplaceText != null && !onReplaceText!(index, len, data)) { |
|
return; |
|
} |
|
|
|
Delta? delta; |
|
if (len > 0 || data is! String || data.isNotEmpty) { |
|
delta = document.replace(index, len, 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); |
|
if (!anyAttributeNotInline) { |
|
shouldRetainDelta = false; |
|
} |
|
} |
|
if (shouldRetainDelta) { |
|
final retainDelta = Delta() |
|
..retain(index) |
|
..retain(data is String ? data.length : 1, toggledStyle.toJson()); |
|
document.compose(retainDelta, ChangeSource.LOCAL); |
|
} |
|
} |
|
|
|
if (_keepStyleOnNewLine) { |
|
final style = getSelectionStyle(); |
|
final notInlineStyle = style.attributes.values.where((s) => !s.isInline); |
|
toggledStyle = style.removeAll(notInlineStyle.toSet()); |
|
} else { |
|
toggledStyle = Style(); |
|
} |
|
|
|
if (textSelection != null) { |
|
if (delta == null || delta.isEmpty) { |
|
_updateSelection(textSelection, ChangeSource.LOCAL); |
|
} else { |
|
final user = Delta() |
|
..retain(index) |
|
..insert(data) |
|
..delete(len); |
|
final positionDelta = getPositionDelta(user, delta); |
|
_updateSelection( |
|
textSelection.copyWith( |
|
baseOffset: textSelection.baseOffset + positionDelta, |
|
extentOffset: textSelection.extentOffset + positionDelta, |
|
), |
|
ChangeSource.LOCAL, |
|
); |
|
} |
|
} |
|
|
|
if (ignoreFocus) { |
|
ignoreFocusOnTextChange = true; |
|
} |
|
notifyListeners(); |
|
ignoreFocusOnTextChange = false; |
|
} |
|
|
|
/// Called in two cases: |
|
/// forward == false && textBefore.isEmpty |
|
/// forward == true && textAfter.isEmpty |
|
/// Android only |
|
/// see https://github.com/singerdmx/flutter-quill/discussions/514 |
|
void handleDelete(int cursorPosition, bool forward) => |
|
onDelete?.call(cursorPosition, forward); |
|
|
|
void formatTextStyle(int index, int len, Style style) { |
|
style.attributes.forEach((key, attr) { |
|
formatText(index, len, attr); |
|
}); |
|
} |
|
|
|
void formatText(int index, int len, Attribute? attribute) { |
|
if (len == 0 && |
|
attribute!.isInline && |
|
attribute.key != Attribute.link.key) { |
|
// Add the attribute to our toggledStyle. |
|
// It will be used later upon insertion. |
|
toggledStyle = toggledStyle.put(attribute); |
|
} |
|
|
|
final change = document.format(index, len, attribute); |
|
// Transform selection against the composed change and give priority to |
|
// the change. This is needed in cases when format operation actually |
|
// inserts data into the document (e.g. embeds). |
|
final adjustedSelection = selection.copyWith( |
|
baseOffset: change.transformPosition(selection.baseOffset), |
|
extentOffset: change.transformPosition(selection.extentOffset)); |
|
if (selection != adjustedSelection) { |
|
_updateSelection(adjustedSelection, ChangeSource.LOCAL); |
|
} |
|
notifyListeners(); |
|
} |
|
|
|
void formatSelection(Attribute? attribute) { |
|
formatText(selection.start, selection.end - selection.start, attribute); |
|
} |
|
|
|
void moveCursorToStart() { |
|
updateSelection( |
|
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL); |
|
} |
|
|
|
void moveCursorToPosition(int position) { |
|
updateSelection( |
|
TextSelection.collapsed(offset: position), ChangeSource.LOCAL); |
|
} |
|
|
|
void moveCursorToEnd() { |
|
updateSelection( |
|
TextSelection.collapsed(offset: plainTextEditingValue.text.length), |
|
ChangeSource.LOCAL); |
|
} |
|
|
|
void updateSelection(TextSelection textSelection, ChangeSource source) { |
|
_updateSelection(textSelection, source); |
|
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(); |
|
} |
|
|
|
@override |
|
void addListener(VoidCallback listener) { |
|
// By using `_isDisposed`, make sure that `addListener` won't be called on a |
|
// disposed `ChangeListener` |
|
if (!_isDisposed) { |
|
super.addListener(listener); |
|
} |
|
} |
|
|
|
@override |
|
void removeListener(VoidCallback listener) { |
|
// By using `_isDisposed`, make sure that `removeListener` won't be called |
|
// on a disposed `ChangeListener` |
|
if (!_isDisposed) { |
|
super.removeListener(listener); |
|
} |
|
} |
|
|
|
@override |
|
void dispose() { |
|
if (!_isDisposed) { |
|
document.close(); |
|
} |
|
|
|
_isDisposed = true; |
|
super.dispose(); |
|
} |
|
|
|
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)); |
|
toggledStyle = Style(); |
|
onSelectionChanged?.call(textSelection); |
|
} |
|
|
|
/// Given offset, find its leaf node in document |
|
Leaf? queryNode(int offset) { |
|
return document.querySegmentLeafNode(offset).leaf; |
|
} |
|
|
|
/// Clipboard for image url and its corresponding style |
|
ImageUrl? _copiedImageUrl; |
|
|
|
ImageUrl? get copiedImageUrl => _copiedImageUrl; |
|
|
|
set copiedImageUrl(ImageUrl? value) { |
|
_copiedImageUrl = value; |
|
Clipboard.setData(const ClipboardData(text: '')); |
|
} |
|
|
|
// Notify toolbar buttons directly with attributes |
|
Map<String, Attribute> toolbarButtonToggler = {}; |
|
}
|
|
|