feat(clipboard): allow pasting markdown and html files from the system to the editor (#1915)

* feat: allow pasting markdown and HTML files from the system into the editor

* chore: format _provideFileAsBytes function parameters

* fix: use onError callback of super_clipboard to work with async await using dart Completer
pull/1916/head
Ellet 10 months ago committed by GitHub
parent 743c829e48
commit 33ba96518d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 128
      flutter_quill_extensions/lib/services/clipboard/super_clipboard_service.dart
  2. 48
      lib/src/models/documents/delta_x.dart
  3. 25
      lib/src/services/clipboard/clipboard_service.dart
  4. 64
      lib/src/services/clipboard/default_clipboard_service.dart
  5. 73
      lib/src/widgets/quill/quill_controller.dart

@ -1,4 +1,5 @@
import 'dart:async' show Completer; import 'dart:async' show Completer;
import 'dart:convert' show utf8;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
// ignore: implementation_imports // ignore: implementation_imports
@ -13,6 +14,17 @@ class SuperClipboardService implements ClipboardService {
return SystemClipboard.instance; return SystemClipboard.instance;
} }
SystemClipboard _getSuperClipboardOrThrow() {
final clipboard = _getSuperClipboard();
if (clipboard == null) {
// To avoid getting this exception, use _canProvide()
throw UnsupportedError(
'Clipboard API is not supported on this platform.',
);
}
return clipboard;
}
Future<bool> _canProvide({required DataFormat format}) async { Future<bool> _canProvide({required DataFormat format}) async {
final clipboard = _getSuperClipboard(); final clipboard = _getSuperClipboard();
if (clipboard == null) { if (clipboard == null) {
@ -22,42 +34,100 @@ class SuperClipboardService implements ClipboardService {
return reader.canProvide(format); return reader.canProvide(format);
} }
Future<Uint8List> _provideFileAsBytes({required FileFormat format}) async { Future<Uint8List> _provideFileAsBytes({
final clipboard = _getSuperClipboard(); required SimpleFileFormat format,
if (clipboard == null) { }) async {
// To avoid getting this exception, use _canProvide() final clipboard = _getSuperClipboardOrThrow();
throw UnsupportedError(
'Clipboard API is not supported on this platform.',
);
}
final reader = await clipboard.read(); final reader = await clipboard.read();
final completer = Completer<Uint8List>(); final completer = Completer<Uint8List>();
reader.getFile(format, (file) async { reader.getFile(
final bytes = await file.readAll(); format,
completer.complete(bytes); (file) async {
}); final bytes = await file.readAll();
completer.complete(bytes);
},
onError: completer.completeError,
);
final bytes = await completer.future; final bytes = await completer.future;
return bytes; return bytes;
} }
Future<String> _provideFileAsString({
required SimpleFileFormat format,
}) async {
final fileBytes = await _provideFileAsBytes(format: format);
final fileText = utf8.decode(fileBytes);
return fileText;
}
/// According to super_clipboard docs, will return `null` if the value /// According to super_clipboard docs, will return `null` if the value
/// is not available or the data is virtual (macOS and Windows) /// is not available or the data is virtual (macOS and Windows)
Future<String?> _provideSimpleValueFormatAsString({ Future<String?> _provideSimpleValueFormatAsString({
required SimpleValueFormat<String> format, required SimpleValueFormat<String> format,
}) async { }) async {
final clipboard = _getSuperClipboard(); final clipboard = _getSuperClipboardOrThrow();
if (clipboard == null) {
// To avoid getting this exception, use _canProvide()
throw UnsupportedError(
'Clipboard API is not supported on this platform.',
);
}
final reader = await clipboard.read(); final reader = await clipboard.read();
final value = await reader.readValue<String>(format); final value = await reader.readValue<String>(format);
return value; return value;
} }
@override
Future<bool> canProvideHtmlText() {
return _canProvide(format: Formats.htmlText);
}
@override
Future<String?> getHtmlText() {
return _provideSimpleValueFormatAsString(format: Formats.htmlText);
}
@override
Future<bool> canProvideHtmlTextFromFile() {
return _canProvide(format: Formats.htmlFile);
}
@override
Future<String?> getHtmlTextFromFile() {
return _provideFileAsString(format: Formats.htmlFile);
}
@override
Future<bool> canProvideMarkdownText() async {
// Formats.markdownText or Formats.mdText does not exist yet in super_clipboard
return false;
}
@override
Future<String?> getMarkdownText() async {
// Formats.markdownText or Formats.mdText does not exist yet in super_clipboard
throw UnsupportedError(
'SuperClipboardService does not support retrieving image files.',
);
}
@override
Future<bool> canProvideMarkdownTextFromFile() async {
// Formats.md is for markdown files
return _canProvide(format: Formats.md);
}
@override
Future<String?> getMarkdownTextFromFile() async {
// Formats.md is for markdown files
return _provideFileAsString(format: Formats.md);
}
@override
Future<bool> canProvidePlainText() {
return _canProvide(format: Formats.plainText);
}
@override
Future<String?> getPlainText() {
return _provideSimpleValueFormatAsString(format: Formats.plainText);
}
/// This will need to be updated if [getImageFileAsBytes] updated. /// This will need to be updated if [getImageFileAsBytes] updated.
/// Notice that even if the copied image is JPEG, it still can be provided /// Notice that even if the copied image is JPEG, it still can be provided
/// as PNG, will handle JPEG check in case this info is incorrect. /// as PNG, will handle JPEG check in case this info is incorrect.
@ -84,26 +154,6 @@ class SuperClipboardService implements ClipboardService {
return _provideFileAsBytes(format: Formats.jpeg); return _provideFileAsBytes(format: Formats.jpeg);
} }
@override
Future<bool> canProvidePlainText() {
return _canProvide(format: Formats.plainText);
}
@override
Future<String?> getPlainText() {
return _provideSimpleValueFormatAsString(format: Formats.plainText);
}
@override
Future<bool> canProvideHtmlText() {
return _canProvide(format: Formats.htmlText);
}
@override
Future<String?> getHtmlText() {
return _provideSimpleValueFormatAsString(format: Formats.htmlText);
}
@override @override
Future<bool> canProvideGifFile() { Future<bool> canProvideGifFile() {
return _canProvide(format: Formats.gif); return _canProvide(format: Formats.gif);

@ -6,43 +6,35 @@ import '../../../markdown_quill.dart';
import '../../../quill_delta.dart'; import '../../../quill_delta.dart';
@immutable @immutable
@experimental
class DeltaX { class DeltaX {
const DeltaX._();
/// Convert Markdown text to [Delta]
///
/// This api is **experimental** and designed to be used **internally** and shouldn't
/// used for **production applications**.
@experimental
static Delta fromMarkdown(String markdownText) {
final mdDocument = md.Document(encodeHtml: false);
final mdToDelta = MarkdownToDelta(markdownDocument: mdDocument);
return mdToDelta.convert(markdownText);
}
/// Convert the HTML Raw string to [Delta] /// Convert the HTML Raw string to [Delta]
/// ///
/// It will run using the following steps: /// It will run using the following steps:
/// ///
/// 1. Convert the html to markdown string using `html2md` package /// 1. Convert the html to markdown string using `html2md` package
/// 2. Convert the markdown string to quill delta json string /// 2. Convert the markdown string to [Delta] using [fromMarkdown]
/// 3. Decode the delta json string to [Delta]
/// ///
/// for more [info](https://github.com/singerdmx/flutter-quill/issues/1100) /// This api is **experimental** and designed to be used **internally** and shouldn't
/// /// used for **production applications**.
/// Please notice that this api is designed to be used internally and shouldn't
/// used for real world applications
/// ///
@experimental @experimental
static Delta fromHtml(String html) { static Delta fromHtml(String htmlText) {
final markdown = html2md final markdownText = html2md.convert(htmlText).replaceAll('unsafe:', '');
.convert(
html,
)
.replaceAll('unsafe:', '');
final mdDocument = md.Document(encodeHtml: false);
final mdToDelta = MarkdownToDelta(markdownDocument: mdDocument);
return mdToDelta.convert(markdown);
// final deltaJsonString = markdownToDelta(markdown); return fromMarkdown(markdownText);
// final deltaJson = jsonDecode(deltaJsonString);
// if (deltaJson is! List) {
// throw ArgumentError(
// 'The delta json string should be of type list when jsonDecode() it',
// );
// }
// return Delta.fromJson(
// deltaJson,
// );
} }
} }

@ -4,8 +4,33 @@ import 'package:flutter/foundation.dart';
@immutable @immutable
abstract class ClipboardService { abstract class ClipboardService {
Future<bool> canProvideHtmlText(); Future<bool> canProvideHtmlText();
/// Get Clipboard content as Html Text, this is platform specific and not the
/// same as [getPlainText] for two reasons:
/// 1. The user might want to paste Html text
/// 2. Copying Html text from other apps and use [getPlainText] will ignore
/// the Html content and provide it as text
Future<String?> getHtmlText(); Future<String?> getHtmlText();
Future<bool> canProvideHtmlTextFromFile();
/// Get the Html file in the Clipboard from the system
Future<String?> getHtmlTextFromFile();
Future<bool> canProvideMarkdownText();
/// Get Clipboard content as Markdown Text, this is platform specific and not the
/// same as [getPlainText] for two reasons:
/// 1. The user might want to paste Markdown text
/// 2. Copying Markdown text from other apps and use [getPlainText] will ignore
/// the Markdown content and provide it as text
Future<String?> getMarkdownText();
Future<bool> canProvideMarkdownTextFromFile();
/// Get the Markdown file in the Clipboard from the system
Future<String?> getMarkdownTextFromFile();
Future<bool> canProvidePlainText(); Future<bool> canProvidePlainText();
Future<String?> getPlainText(); Future<String?> getPlainText();

@ -5,40 +5,70 @@ import 'clipboard_service.dart';
/// Default implementation using only internal flutter plugins /// Default implementation using only internal flutter plugins
class DefaultClipboardService implements ClipboardService { class DefaultClipboardService implements ClipboardService {
@override @override
Future<bool> canProvideGifFile() async { Future<bool> canProvideHtmlText() async {
return false; return false;
} }
@override @override
Future<bool> canProvideHtmlText() async { Future<String?> getHtmlText() {
return false; throw UnsupportedError(
'DefaultClipboardService does not support retrieving HTML text.',
);
} }
@override @override
Future<bool> canProvideImageFile() async { Future<bool> canProvideHtmlTextFromFile() async {
return false; return false;
} }
@override @override
Future<bool> canProvidePlainText() async { Future<String?> getHtmlTextFromFile() {
final plainText = (await Clipboard.getData(Clipboard.kTextPlain))?.text; throw UnsupportedError(
return plainText == null; 'DefaultClipboardService does not support retrieving HTML files.',
);
} }
@override @override
Future<Uint8List> getGifFileAsBytes() { Future<bool> canProvideMarkdownText() async {
return false;
}
@override
Future<String?> getMarkdownText() {
throw UnsupportedError( throw UnsupportedError(
'DefaultClipboardService does not support retrieving GIF files.', 'DefaultClipboardService does not support retrieving HTML files.',
); );
} }
@override @override
Future<String?> getHtmlText() { Future<bool> canProvideMarkdownTextFromFile() async {
return false;
}
@override
Future<String?> getMarkdownTextFromFile() {
throw UnsupportedError( throw UnsupportedError(
'DefaultClipboardService does not support retrieving HTML text.', 'DefaultClipboardService does not support retrieving Markdown text.',
); );
} }
@override
Future<bool> canProvidePlainText() async {
final plainText = (await Clipboard.getData(Clipboard.kTextPlain))?.text;
return plainText == null;
}
@override
Future<String?> getPlainText() async {
final plainText = (await Clipboard.getData(Clipboard.kTextPlain))?.text;
return plainText;
}
@override
Future<bool> canProvideImageFile() async {
return false;
}
@override @override
Future<Uint8List> getImageFileAsBytes() { Future<Uint8List> getImageFileAsBytes() {
throw UnsupportedError( throw UnsupportedError(
@ -47,9 +77,15 @@ class DefaultClipboardService implements ClipboardService {
} }
@override @override
Future<String?> getPlainText() async { Future<bool> canProvideGifFile() async {
final plainText = (await Clipboard.getData(Clipboard.kTextPlain))?.text; return false;
return plainText; }
@override
Future<Uint8List> getGifFileAsBytes() {
throw UnsupportedError(
'DefaultClipboardService does not support retrieving GIF files.',
);
} }
@override @override

@ -507,7 +507,14 @@ class QuillController extends ChangeNotifier {
Future<bool> clipboardPaste({void Function()? updateEditor}) async { Future<bool> clipboardPaste({void Function()? updateEditor}) async {
if (readOnly || !selection.isValid) return true; if (readOnly || !selection.isValid) return true;
if (await _pasteHTML()) { final pasteUsingHtmlSuccess = await _pasteHTML();
if (pasteUsingHtmlSuccess) {
updateEditor?.call();
return true;
}
final pasteUsingMarkdownSuccess = await _pasteMarkdown();
if (pasteUsingMarkdownSuccess) {
updateEditor?.call(); updateEditor?.call();
return true; return true;
} }
@ -550,24 +557,60 @@ class QuillController extends ChangeNotifier {
return false; return false;
} }
/// True if can paste using HTML void _pasteUsingDelta(Delta deltaFromClipboard) {
replaceText(
selection.start,
selection.end - selection.start,
deltaFromClipboard,
TextSelection.collapsed(offset: selection.end),
);
}
/// Return true if can paste using HTML
Future<bool> _pasteHTML() async { Future<bool> _pasteHTML() async {
final clipboardService = ClipboardServiceProvider.instacne; final clipboardService = ClipboardServiceProvider.instacne;
if (await clipboardService.canProvideHtmlText()) {
final html = await clipboardService.getHtmlText();
if (html == null) { Future<String?> getHTML() async {
return false; if (await clipboardService.canProvideHtmlTextFromFile()) {
return await clipboardService.getHtmlTextFromFile();
} }
final htmlBody = html_parser.parse(html).body?.outerHtml; if (await clipboardService.canProvideHtmlText()) {
final deltaFromClipboard = DeltaX.fromHtml(htmlBody ?? html); return await clipboardService.getHtmlText();
}
replaceText( return null;
selection.start, }
selection.end - selection.start,
deltaFromClipboard, final htmlText = await getHTML();
TextSelection.collapsed(offset: selection.end), if (htmlText != null) {
); final htmlBody = html_parser.parse(htmlText).body?.outerHtml;
final deltaFromClipboard = DeltaX.fromHtml(htmlBody ?? htmlText);
_pasteUsingDelta(deltaFromClipboard);
return true;
}
return false;
}
/// Return true if can paste using Markdown
Future<bool> _pasteMarkdown() async {
final clipboardService = ClipboardServiceProvider.instacne;
Future<String?> getMarkdown() async {
if (await clipboardService.canProvideMarkdownTextFromFile()) {
return await clipboardService.getMarkdownTextFromFile();
}
if (await clipboardService.canProvideMarkdownText()) {
return await clipboardService.getMarkdownText();
}
return null;
}
final markdownText = await getMarkdown();
if (markdownText != null) {
final deltaFromClipboard = DeltaX.fromMarkdown(markdownText);
_pasteUsingDelta(deltaFromClipboard);
return true; return true;
} }

Loading…
Cancel
Save