feat: allow pasting markdown and html files from the system to the editor

pull/1915/head
Ellet 10 months ago
parent 743c829e48
commit f78789efe0
  1. 115
      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. 63
      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,14 +34,9 @@ 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}) async {
if (clipboard == null) { final clipboard = _getSuperClipboardOrThrow();
// 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 completer = Completer<Uint8List>(); final completer = Completer<Uint8List>();
@ -41,47 +48,69 @@ class SuperClipboardService implements ClipboardService {
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;
} }
/// This will need to be updated if [getImageFileAsBytes] updated.
/// 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.
@override @override
Future<bool> canProvideImageFile() async { Future<bool> canProvideHtmlText() {
final canProvidePngFile = await _canProvide(format: Formats.png); return _canProvide(format: Formats.htmlText);
if (canProvidePngFile) {
return true;
} }
final canProvideJpegFile = await _canProvide(format: Formats.jpeg);
if (canProvideJpegFile) { @override
return true; 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; return false;
} }
/// This will need to be updated if [canProvideImageFile] updated.
@override @override
Future<Uint8List> getImageFileAsBytes() async { Future<String?> getMarkdownText() async {
final canProvidePngFile = await _canProvide(format: Formats.png); // Formats.markdownText or Formats.mdText does not exist yet in super_clipboard
if (canProvidePngFile) { throw UnsupportedError(
return _provideFileAsBytes(format: Formats.png); 'SuperClipboardService does not support retrieving image files.',
);
} }
return _provideFileAsBytes(format: Formats.jpeg);
@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 @override
@ -94,14 +123,30 @@ class SuperClipboardService implements ClipboardService {
return _provideSimpleValueFormatAsString(format: Formats.plainText); return _provideSimpleValueFormatAsString(format: Formats.plainText);
} }
/// This will need to be updated if [getImageFileAsBytes] updated.
/// 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.
@override @override
Future<bool> canProvideHtmlText() { Future<bool> canProvideImageFile() async {
return _canProvide(format: Formats.htmlText); final canProvidePngFile = await _canProvide(format: Formats.png);
if (canProvidePngFile) {
return true;
}
final canProvideJpegFile = await _canProvide(format: Formats.jpeg);
if (canProvideJpegFile) {
return true;
}
return false;
} }
/// This will need to be updated if [canProvideImageFile] updated.
@override @override
Future<String?> getHtmlText() { Future<Uint8List> getImageFileAsBytes() async {
return _provideSimpleValueFormatAsString(format: Formats.htmlText); final canProvidePngFile = await _canProvide(format: Formats.png);
if (canProvidePngFile) {
return _provideFileAsBytes(format: Formats.png);
}
return _provideFileAsBytes(format: Formats.jpeg);
} }
@override @override

@ -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

@ -512,6 +512,11 @@ class QuillController extends ChangeNotifier {
return true; return true;
} }
if (await _pasteMarkdown()) {
updateEditor?.call();
return true;
}
// Snapshot the input before using `await`. // Snapshot the input before using `await`.
// See https://github.com/flutter/flutter/issues/11427 // See https://github.com/flutter/flutter/issues/11427
final plainTextClipboardData = final plainTextClipboardData =
@ -550,24 +555,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;
Future<String?> getHTML() async {
if (await clipboardService.canProvideHtmlTextFromFile()) {
return await clipboardService.getHtmlTextFromFile();
}
if (await clipboardService.canProvideHtmlText()) { if (await clipboardService.canProvideHtmlText()) {
final html = await clipboardService.getHtmlText(); return await clipboardService.getHtmlText();
}
return null;
}
final htmlText = await getHTML();
if (htmlText != null) {
final htmlBody = html_parser.parse(htmlText).body?.outerHtml;
final deltaFromClipboard = DeltaX.fromHtml(htmlBody ?? htmlText);
if (html == null) { _pasteUsingDelta(deltaFromClipboard);
return true;
}
return false; return false;
} }
final htmlBody = html_parser.parse(html).body?.outerHtml;
final deltaFromClipboard = DeltaX.fromHtml(htmlBody ?? html);
replaceText( /// Return true if can paste using Markdown
selection.start, Future<bool> _pasteMarkdown() async {
selection.end - selection.start, final clipboardService = ClipboardServiceProvider.instacne;
deltaFromClipboard,
TextSelection.collapsed(offset: selection.end), 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