Move clipboard actions to QuillController

pull/1843/head
Douglas Ward 1 year ago
parent 5249ad3c4c
commit 2334d79e3d
  1. 209
      lib/src/widgets/quill/quill_controller.dart
  2. 4
      lib/src/widgets/raw_editor/raw_editor.dart
  3. 131
      lib/src/widgets/raw_editor/raw_editor_state.dart
  4. 60
      lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart

@ -2,10 +2,13 @@ import 'dart:math' as math;
import 'package:flutter/services.dart' show ClipboardData, Clipboard; import 'package:flutter/services.dart' show ClipboardData, Clipboard;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:super_clipboard/super_clipboard.dart';
import '../../../quill_delta.dart'; import '../../../quill_delta.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/delta_x.dart';
import '../../models/documents/document.dart'; import '../../models/documents/document.dart';
import '../../models/documents/nodes/embeddable.dart'; import '../../models/documents/nodes/embeddable.dart';
import '../../models/documents/nodes/leaf.dart'; import '../../models/documents/nodes/leaf.dart';
@ -14,6 +17,7 @@ import '../../models/structs/doc_change.dart';
import '../../models/structs/image_url.dart'; import '../../models/structs/image_url.dart';
import '../../models/structs/offset_value.dart'; import '../../models/structs/offset_value.dart';
import '../../utils/delta.dart'; import '../../utils/delta.dart';
import '../../utils/embeds.dart';
typedef ReplaceTextCallback = bool Function(int index, int len, Object? data); typedef ReplaceTextCallback = bool Function(int index, int len, Object? data);
typedef DeleteCallback = void Function(int cursorPosition, bool forward); typedef DeleteCallback = void Function(int cursorPosition, bool forward);
@ -111,9 +115,7 @@ class QuillController extends ChangeNotifier {
/// Only attributes applied to all characters within this range are /// Only attributes applied to all characters within this range are
/// included in the result. /// included in the result.
Style getSelectionStyle() { Style getSelectionStyle() {
return document return document.collectStyle(selection.start, selection.end - selection.start).mergeAll(toggledStyle);
.collectStyle(selection.start, selection.end - selection.start)
.mergeAll(toggledStyle);
} }
// Increases or decreases the indent of the current selection by 1. // Increases or decreases the indent of the current selection by 1.
@ -182,23 +184,19 @@ class QuillController extends ChangeNotifier {
/// Returns all styles and Embed for each node within selection /// Returns all styles and Embed for each node within selection
List<OffsetValue> getAllIndividualSelectionStylesAndEmbed() { List<OffsetValue> getAllIndividualSelectionStylesAndEmbed() {
final stylesAndEmbed = document.collectAllIndividualStyleAndEmbed( final stylesAndEmbed = document.collectAllIndividualStyleAndEmbed(selection.start, selection.end - selection.start);
selection.start, selection.end - selection.start);
return stylesAndEmbed; return stylesAndEmbed;
} }
/// Returns plain text for each node within selection /// Returns plain text for each node within selection
String getPlainText() { String getPlainText() {
final text = final text = document.getPlainText(selection.start, selection.end - selection.start);
document.getPlainText(selection.start, selection.end - selection.start);
return text; return text;
} }
/// Returns all styles for any character within the specified text range. /// Returns all styles for any character within the specified text range.
List<Style> getAllSelectionStyles() { List<Style> getAllSelectionStyles() {
final styles = document.collectAllStyles( final styles = document.collectAllStyles(selection.start, selection.end - selection.start)..add(toggledStyle);
selection.start, selection.end - selection.start)
..add(toggledStyle);
return styles; return styles;
} }
@ -244,8 +242,7 @@ class QuillController extends ChangeNotifier {
/// clear editor /// clear editor
void clear() { void clear() {
replaceText(0, plainTextEditingValue.text.length - 1, '', replaceText(0, plainTextEditingValue.text.length - 1, '', const TextSelection.collapsed(offset: 0));
const TextSelection.collapsed(offset: 0));
} }
void replaceText( void replaceText(
@ -271,13 +268,9 @@ class QuillController extends ChangeNotifier {
delta.last.isInsert && delta.last.isInsert &&
// pasted text should not use toggledStyle // pasted text should not use toggledStyle
(data is! String || data.length < 2); (data is! String || data.length < 2);
if (shouldRetainDelta && if (shouldRetainDelta && toggledStyle.isNotEmpty && delta.length == 2 && delta.last.data == '\n') {
toggledStyle.isNotEmpty &&
delta.length == 2 &&
delta.last.data == '\n') {
// if all attributes are inline, shouldRetainDelta should be false // if all attributes are inline, shouldRetainDelta should be false
final anyAttributeNotInline = final anyAttributeNotInline = toggledStyle.values.any((attr) => !attr.isInline);
toggledStyle.values.any((attr) => !attr.isInline);
if (!anyAttributeNotInline) { if (!anyAttributeNotInline) {
shouldRetainDelta = false; shouldRetainDelta = false;
} }
@ -322,8 +315,7 @@ class QuillController extends ChangeNotifier {
/// forward == true && textAfter.isEmpty /// forward == true && textAfter.isEmpty
/// Android only /// Android only
/// see https://github.com/singerdmx/flutter-quill/discussions/514 /// see https://github.com/singerdmx/flutter-quill/discussions/514
void handleDelete(int cursorPosition, bool forward) => void handleDelete(int cursorPosition, bool forward) => onDelete?.call(cursorPosition, forward);
onDelete?.call(cursorPosition, forward);
void formatTextStyle(int index, int len, Style style) { void formatTextStyle(int index, int len, Style style) {
style.attributes.forEach((key, attr) { style.attributes.forEach((key, attr) {
@ -337,9 +329,7 @@ class QuillController extends ChangeNotifier {
Attribute? attribute, { Attribute? attribute, {
bool shouldNotifyListeners = true, bool shouldNotifyListeners = true,
}) { }) {
if (len == 0 && if (len == 0 && attribute!.isInline && attribute.key != Attribute.link.key) {
attribute!.isInline &&
attribute.key != Attribute.link.key) {
// Add the attribute to our toggledStyle. // Add the attribute to our toggledStyle.
// It will be used later upon insertion. // It will be used later upon insertion.
toggledStyle = toggledStyle.put(attribute); toggledStyle = toggledStyle.put(attribute);
@ -349,9 +339,7 @@ class QuillController extends ChangeNotifier {
// Transform selection against the composed change and give priority to // Transform selection against the composed change and give priority to
// the change. This is needed in cases when format operation actually // the change. This is needed in cases when format operation actually
// inserts data into the document (e.g. embeds). // inserts data into the document (e.g. embeds).
final adjustedSelection = selection.copyWith( final adjustedSelection = selection.copyWith(baseOffset: change.transformPosition(selection.baseOffset), extentOffset: change.transformPosition(selection.extentOffset));
baseOffset: change.transformPosition(selection.baseOffset),
extentOffset: change.transformPosition(selection.extentOffset));
if (selection != adjustedSelection) { if (selection != adjustedSelection) {
_updateSelection(adjustedSelection); _updateSelection(adjustedSelection);
} }
@ -360,8 +348,7 @@ class QuillController extends ChangeNotifier {
} }
} }
void formatSelection(Attribute? attribute, void formatSelection(Attribute? attribute, {bool shouldNotifyListeners = true}) {
{bool shouldNotifyListeners = true}) {
formatText( formatText(
selection.start, selection.start,
selection.end - selection.start, selection.end - selection.start,
@ -443,13 +430,10 @@ class QuillController extends ChangeNotifier {
super.dispose(); super.dispose();
} }
void _updateSelection(TextSelection textSelection, void _updateSelection(TextSelection textSelection, {bool insertNewline = false}) {
{bool insertNewline = false}) {
_selection = textSelection; _selection = textSelection;
final end = document.length - 1; final end = document.length - 1;
_selection = selection.copyWith( _selection = selection.copyWith(baseOffset: math.min(selection.baseOffset, end), extentOffset: math.min(selection.extentOffset, end));
baseOffset: math.min(selection.baseOffset, end),
extentOffset: math.min(selection.extentOffset, end));
if (keepStyleOnNewLine) { if (keepStyleOnNewLine) {
if (insertNewline && selection.start > 0) { if (insertNewline && selection.start > 0) {
final style = document.collectStyle(selection.start - 1, 0); final style = document.collectStyle(selection.start - 1, 0);
@ -471,9 +455,18 @@ class QuillController extends ChangeNotifier {
return document.querySegmentLeafNode(offset).leaf; return document.querySegmentLeafNode(offset).leaf;
} }
/// Clipboard for image url and its corresponding style // Notify toolbar buttons directly with attributes
ImageUrl? _copiedImageUrl; Map<String, Attribute> toolbarButtonToggler = const {};
/// Clipboard caches last copy to allow paste with styles. Static to allow paste between multiple instances of editor.
static String _pastePlainText = '';
static List<OffsetValue> _pasteStyleAndEmbed = <OffsetValue>[];
String get pastePlainText => _pastePlainText;
List<OffsetValue> get pasteStyleAndEmbed => _pasteStyleAndEmbed;
bool readOnly = false;
ImageUrl? _copiedImageUrl;
ImageUrl? get copiedImageUrl => _copiedImageUrl; ImageUrl? get copiedImageUrl => _copiedImageUrl;
set copiedImageUrl(ImageUrl? value) { set copiedImageUrl(ImageUrl? value) {
@ -481,6 +474,148 @@ class QuillController extends ChangeNotifier {
Clipboard.setData(const ClipboardData(text: '')); Clipboard.setData(const ClipboardData(text: ''));
} }
// Notify toolbar buttons directly with attributes bool clipboardSelection(bool copy) {
Map<String, Attribute> toolbarButtonToggler = const {}; copiedImageUrl = null;
_pastePlainText = getPlainText();
_pasteStyleAndEmbed = getAllIndividualSelectionStylesAndEmbed();
if (!selection.isCollapsed) {
Clipboard.setData(ClipboardData(text: _pastePlainText));
if (!copy) {
final sel = selection;
replaceText(sel.start, sel.end - sel.start, '', TextSelection.collapsed(offset: sel.start));
}
return true;
}
return false;
}
/// Returns whether paste was handled here.
Future<bool> clipboardPaste({void Function()? updateEditor}) async {
if (readOnly) return true;
// When image copied internally in the editor
if (_copiedImageUrl != null) {
final index = selection.baseOffset;
final length = selection.extentOffset - index;
replaceText(
index,
length,
BlockEmbed.image(_copiedImageUrl!.url),
null,
);
if (_copiedImageUrl!.styleString.isNotEmpty) {
formatText(
getEmbedNode(this, index + 1).offset,
1,
StyleAttribute(_copiedImageUrl!.styleString),
);
}
copiedImageUrl = null;
return true;
}
if (!selection.isValid) {
return true;
}
if (await _pasteHTML()) {
updateEditor?.call();
return true;
}
// Snapshot the input before using `await`.
// See https://github.com/flutter/flutter/issues/11427
final plainText = await Clipboard.getData(Clipboard.kTextPlain);
if (plainText != null) {
replaceTextWithEmbeds(
selection.start,
selection.end - selection.start,
plainText.text!,
TextSelection.collapsed(offset: selection.start + plainText.text!.length),
);
updateEditor?.call();
return true;
}
return false;
}
Future<bool> _pasteHTML() async {
final clipboard = SystemClipboard.instance;
if (clipboard != null) {
final reader = await clipboard.read();
if (reader.canProvide(Formats.htmlText)) {
final html = await reader.readValue(Formats.htmlText);
if (html == null) {
return false;
}
final htmlBody = html_parser.parse(html).body?.outerHtml;
final deltaFromClipboard = DeltaX.fromHtml(htmlBody ?? html);
replaceText(
selection.start,
selection.end - selection.start,
deltaFromClipboard,
TextSelection.collapsed(offset: selection.end),
);
return true;
}
}
return false;
}
void replaceTextWithEmbeds(
int index,
int len,
String insertedText,
TextSelection? textSelection, {
bool ignoreFocus = false,
bool shouldNotifyListeners = true,
}) {
final containsEmbed = insertedText.codeUnits.contains(Embed.kObjectReplacementInt);
insertedText = containsEmbed ? _adjustInsertedText(insertedText) : insertedText;
replaceText(index, len, insertedText, textSelection, ignoreFocus: ignoreFocus, shouldNotifyListeners: shouldNotifyListeners);
_applyPasteStyleAndEmbed(insertedText, index, containsEmbed);
}
void _applyPasteStyleAndEmbed(String insertedText, int start, bool containsEmbed) {
if (insertedText == pastePlainText && pastePlainText != '' || containsEmbed) {
final pos = start;
for (final p in pasteStyleAndEmbed) {
final offset = p.offset;
final styleAndEmbed = p.value;
final local = pos + offset;
if (styleAndEmbed is Embeddable) {
replaceText(local, 0, styleAndEmbed, null);
} else {
final style = styleAndEmbed as Style;
if (style.isInline) {
formatTextStyle(local, p.length!, style);
} else if (style.isBlock) {
final node = document.queryChild(local).node;
if (node != null && p.length == node.length - 1) {
for (final attribute in style.values) {
document.format(local, 0, attribute);
}
}
}
}
}
}
}
String _adjustInsertedText(String text) {
final sb = StringBuffer();
for (var i = 0; i < text.length; i++) {
if (text.codeUnitAt(i) == Embed.kObjectReplacementInt) {
continue;
}
sb.write(text[i]);
}
return sb.toString();
}
} }

@ -29,7 +29,9 @@ class QuillRawEditor extends StatefulWidget {
configurations.maxHeight == null || configurations.maxHeight == null ||
configurations.minHeight == null || configurations.minHeight == null ||
configurations.maxHeight! >= configurations.minHeight!, configurations.maxHeight! >= configurations.minHeight!,
'maxHeight cannot be null'); 'maxHeight cannot be null') {
configurations.controller.readOnly = configurations.readOnly;
}
final QuillRawEditorConfigurations configurations; final QuillRawEditorConfigurations configurations;

@ -11,7 +11,6 @@ import 'package:flutter/scheduler.dart' show SchedulerBinding;
import 'package:flutter/services.dart' import 'package:flutter/services.dart'
show show
Clipboard, Clipboard,
ClipboardData,
HardwareKeyboard, HardwareKeyboard,
LogicalKeyboardKey, LogicalKeyboardKey,
KeyDownEvent, KeyDownEvent,
@ -19,11 +18,9 @@ import 'package:flutter/services.dart'
TextInputControl; TextInputControl;
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart' import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'
show KeyboardVisibilityController; show KeyboardVisibilityController;
import 'package:html/parser.dart' as html_parser;
import 'package:super_clipboard/super_clipboard.dart'; import 'package:super_clipboard/super_clipboard.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/delta_x.dart';
import '../../models/documents/document.dart'; import '../../models/documents/document.dart';
import '../../models/documents/nodes/block.dart'; import '../../models/documents/nodes/block.dart';
import '../../models/documents/nodes/embeddable.dart'; import '../../models/documents/nodes/embeddable.dart';
@ -34,7 +31,6 @@ import '../../models/structs/offset_value.dart';
import '../../models/structs/vertical_spacing.dart'; import '../../models/structs/vertical_spacing.dart';
import '../../utils/cast.dart'; import '../../utils/cast.dart';
import '../../utils/delta.dart'; import '../../utils/delta.dart';
import '../../utils/embeds.dart';
import '../../utils/platform.dart'; import '../../utils/platform.dart';
import '../editor/editor.dart'; import '../editor/editor.dart';
import '../others/cursor.dart'; import '../others/cursor.dart';
@ -92,12 +88,10 @@ class QuillRawEditorState extends EditorState
// for pasting style // for pasting style
@override @override
List<OffsetValue> get pasteStyleAndEmbed => _pasteStyleAndEmbed; List<OffsetValue> get pasteStyleAndEmbed => controller.pasteStyleAndEmbed;
List<OffsetValue> _pasteStyleAndEmbed = <OffsetValue>[];
@override @override
String get pastePlainText => _pastePlainText; String get pastePlainText => controller.pastePlainText;
String _pastePlainText = '';
ClipboardStatusNotifier? _clipboardStatus; ClipboardStatusNotifier? _clipboardStatus;
final LayerLink _toolbarLayerLink = LayerLink(); final LayerLink _toolbarLayerLink = LayerLink();
@ -122,16 +116,7 @@ class QuillRawEditorState extends EditorState
/// Copy current selection to [Clipboard]. /// Copy current selection to [Clipboard].
@override @override
void copySelection(SelectionChangedCause cause) { void copySelection(SelectionChangedCause cause) {
controller.copiedImageUrl = null; if ( !controller.clipboardSelection(true)) return;
_pastePlainText = controller.getPlainText();
_pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed();
final selection = textEditingValue.selection;
final text = textEditingValue.text;
if (selection.isCollapsed) {
return;
}
Clipboard.setData(ClipboardData(text: selection.textInside(text)));
if (cause == SelectionChangedCause.toolbar) { if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent); bringIntoView(textEditingValue.selection.extent);
@ -152,20 +137,7 @@ class QuillRawEditorState extends EditorState
/// Cut current selection to [Clipboard]. /// Cut current selection to [Clipboard].
@override @override
void cutSelection(SelectionChangedCause cause) { void cutSelection(SelectionChangedCause cause) {
controller.copiedImageUrl = null; if ( !controller.clipboardSelection(false)) return;
_pastePlainText = controller.getPlainText();
_pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed();
if (widget.configurations.readOnly) {
return;
}
final selection = textEditingValue.selection;
final text = textEditingValue.text;
if (selection.isCollapsed) {
return;
}
Clipboard.setData(ClipboardData(text: selection.textInside(text)));
_replaceText(ReplaceTextIntent(textEditingValue, '', selection, cause));
if (cause == SelectionChangedCause.toolbar) { if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent); bringIntoView(textEditingValue.selection.extent);
@ -176,105 +148,13 @@ class QuillRawEditorState extends EditorState
/// Paste text from [Clipboard]. /// Paste text from [Clipboard].
@override @override
Future<void> pasteText(SelectionChangedCause cause) async { Future<void> pasteText(SelectionChangedCause cause) async {
if (widget.configurations.readOnly) {
return;
}
// When image copied internally in the editor if ( await controller.clipboardPaste(updateEditor: () => bringIntoView(textEditingValue.selection.extent) ) ) {
final copiedImageUrl = controller.copiedImageUrl;
if (copiedImageUrl != null) {
final index = textEditingValue.selection.baseOffset;
final length = textEditingValue.selection.extentOffset - index;
controller.replaceText(
index,
length,
BlockEmbed.image(copiedImageUrl.url),
null,
);
if (copiedImageUrl.styleString.isNotEmpty) {
controller.formatText(
getEmbedNode(controller, index + 1).offset,
1,
StyleAttribute(copiedImageUrl.styleString),
);
}
controller.copiedImageUrl = null;
await Clipboard.setData(
const ClipboardData(text: ''),
);
return;
}
final selection = textEditingValue.selection;
if (!selection.isValid) {
return; return;
} }
final clipboard = SystemClipboard.instance; final clipboard = SystemClipboard.instance;
if (clipboard != null) {
final reader = await clipboard.read();
if (reader.canProvide(Formats.htmlText)) {
final html = await reader.readValue(Formats.htmlText);
if (html == null) {
return;
}
final htmlBody = html_parser.parse(html).body?.outerHtml;
final deltaFromClipboard = DeltaX.fromHtml(htmlBody ?? html);
controller.replaceText(
textEditingValue.selection.start,
textEditingValue.selection.end - textEditingValue.selection.start,
deltaFromClipboard,
TextSelection.collapsed(offset: textEditingValue.selection.end),
);
bringIntoView(textEditingValue.selection.extent);
// Collapse the selection and hide the toolbar and handles.
userUpdateTextEditingValue(
TextEditingValue(
text: textEditingValue.text,
selection: TextSelection.collapsed(
offset: textEditingValue.selection.end,
),
),
cause,
);
return;
}
}
// Snapshot the input before using `await`.
// See https://github.com/flutter/flutter/issues/11427
final plainText = await Clipboard.getData(Clipboard.kTextPlain);
if (plainText != null) {
_replaceText(
ReplaceTextIntent(
textEditingValue,
plainText.text!,
selection,
cause,
),
);
bringIntoView(textEditingValue.selection.extent);
// Collapse the selection and hide the toolbar and handles.
userUpdateTextEditingValue(
TextEditingValue(
text: textEditingValue.text,
selection: TextSelection.collapsed(
offset: textEditingValue.selection.end,
),
),
cause,
);
return;
}
final onImagePaste = widget.configurations.onImagePaste; final onImagePaste = widget.configurations.onImagePaste;
if (onImagePaste != null) { if (onImagePaste != null) {
if (clipboard != null) { if (clipboard != null) {
@ -322,7 +202,6 @@ class QuillRawEditorState extends EditorState
} }
} }
} }
return;
} }
/// Select the entire text value. /// Select the entire text value.

@ -4,9 +4,6 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../../models/documents/document.dart'; import '../../models/documents/document.dart';
import '../../models/documents/nodes/embeddable.dart';
import '../../models/documents/nodes/leaf.dart';
import '../../models/documents/style.dart';
import '../../utils/delta.dart'; import '../../utils/delta.dart';
import 'raw_editor.dart'; import 'raw_editor.dart';
@ -29,62 +26,7 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
return; return;
} }
var insertedText = diff.inserted; widget.configurations.controller.replaceTextWithEmbeds(diff.start, diff.deleted.length, diff.inserted, value.selection);
final containsEmbed =
insertedText.codeUnits.contains(Embed.kObjectReplacementInt);
insertedText =
containsEmbed ? _adjustInsertedText(diff.inserted) : diff.inserted;
widget.configurations.controller.replaceText(
diff.start, diff.deleted.length, insertedText, value.selection);
_applyPasteStyleAndEmbed(insertedText, diff.start, containsEmbed);
}
void _applyPasteStyleAndEmbed(
String insertedText, int start, bool containsEmbed) {
if (insertedText == pastePlainText && pastePlainText != '' ||
containsEmbed) {
final pos = start;
for (var i = 0; i < pasteStyleAndEmbed.length; i++) {
final offset = pasteStyleAndEmbed[i].offset;
final styleAndEmbed = pasteStyleAndEmbed[i].value;
final local = pos + offset;
if (styleAndEmbed is Embeddable) {
widget.configurations.controller
.replaceText(local, 0, styleAndEmbed, null);
} else {
final style = styleAndEmbed as Style;
if (style.isInline) {
widget.configurations.controller
.formatTextStyle(local, pasteStyleAndEmbed[i].length!, style);
} else if (style.isBlock) {
final node = widget.configurations.controller.document
.queryChild(local)
.node;
if (node != null &&
pasteStyleAndEmbed[i].length == node.length - 1) {
for (final attribute in style.values) {
widget.configurations.controller.document
.format(local, 0, attribute);
}
}
}
}
}
}
}
String _adjustInsertedText(String text) {
final sb = StringBuffer();
for (var i = 0; i < text.length; i++) {
if (text.codeUnitAt(i) == Embed.kObjectReplacementInt) {
continue;
}
sb.write(text[i]);
}
return sb.toString();
} }
@override @override

Loading…
Cancel
Save