From c39b8072b776acd543c5711c165c0cd6480bc8db Mon Sep 17 00:00:00 2001 From: Alan Mantoux Date: Sun, 3 Dec 2023 12:21:45 +0100 Subject: [PATCH] Auto formats and auto link implementation --- packages/fleather/lib/fleather.dart | 1 + packages/fleather/lib/src/util.dart | 15 +++ .../fleather/lib/src/widgets/autoformats.dart | 109 ++++++++++++++++++ .../fleather/lib/src/widgets/controller.dart | 35 +++++- .../fleather/lib/src/widgets/history.dart | 15 +-- packages/parchment/lib/src/heuristics.dart | 3 - .../lib/src/heuristics/insert_rules.dart | 59 +--------- .../test/heuristics/insert_rules_test.dart | 50 -------- 8 files changed, 160 insertions(+), 127 deletions(-) create mode 100644 packages/fleather/lib/src/util.dart create mode 100644 packages/fleather/lib/src/widgets/autoformats.dart diff --git a/packages/fleather/lib/fleather.dart b/packages/fleather/lib/fleather.dart index b7c52392..b927eac4 100644 --- a/packages/fleather/lib/fleather.dart +++ b/packages/fleather/lib/fleather.dart @@ -18,3 +18,4 @@ export 'src/widgets/field.dart'; export 'src/widgets/link.dart' show LinkActionPickerDelegate, LinkMenuAction; export 'src/widgets/text_line.dart'; export 'src/widgets/theme.dart'; +export 'src/widgets/autoformats.dart'; diff --git a/packages/fleather/lib/src/util.dart b/packages/fleather/lib/src/util.dart new file mode 100644 index 00000000..b78ac5ee --- /dev/null +++ b/packages/fleather/lib/src/util.dart @@ -0,0 +1,15 @@ +import 'package:quill_delta/quill_delta.dart'; + +extension DeltaExtension on Delta { + int get textLength { + int length = 0; + toList().forEach((op) { + if (op.isDelete) { + length -= op.length; + } else { + length += op.length; + } + }); + return length; + } +} diff --git a/packages/fleather/lib/src/widgets/autoformats.dart b/packages/fleather/lib/src/widgets/autoformats.dart new file mode 100644 index 00000000..a540b2b8 --- /dev/null +++ b/packages/fleather/lib/src/widgets/autoformats.dart @@ -0,0 +1,109 @@ +import 'package:fleather/fleather.dart'; +import 'package:quill_delta/quill_delta.dart'; + +/// An [AutoFormat] is responsible for looking back for a pattern and apply a +/// formatting suggestion. +/// +/// For example, identify a link a automatically wrap it with a link attribute or +/// apply formatting using Markdown shortcuts +abstract class AutoFormat { + const AutoFormat(); + + /// Upon upon insertion of a space or new line run format detection + /// Returns a [Delta] with the resulting change to apply to th document + Delta? apply(Delta document, int position, String data); +} + +/// Registry for [AutoFormats]. +class AutoFormats { + AutoFormats({required List autoFormats}) + : _autoFormats = autoFormats; + + /// Default set of autoformats. + factory AutoFormats.fallback() { + return AutoFormats(autoFormats: [const _AutoFormatLinks()]); + } + + final List _autoFormats; + + Delta? get activeSuggestion => _activeSuggestion; + Delta? _activeSuggestion; + Delta? _undoActiveSuggestion; + + bool get hasActiveSuggestion => _activeSuggestion != null; + + /// Perform detection of auto formats and apply changes to [document] + /// + /// Inserted data must be of type [String] + void run(ParchmentDocument document, int position, Object data) { + if (data is! String || data.isEmpty) return; + + Delta documentDelta = document.toDelta(); + for (final autoFormat in _autoFormats) { + _activeSuggestion = autoFormat.apply(documentDelta, position, data) + ?..trim(); + if (_activeSuggestion != null) { + _undoActiveSuggestion = _activeSuggestion!.invert(documentDelta); + document.compose(_activeSuggestion!, ChangeSource.local); + return; + } + } + } + + /// Remove auto format from [document] and de-activate current suggestion + void undoActive(ParchmentDocument document) { + if (_activeSuggestion == null) return; + document.compose(_undoActiveSuggestion!, ChangeSource.local); + _undoActiveSuggestion = null; + _activeSuggestion = null; + } + + /// Cancel active auto format + void cancelActive() { + _undoActiveSuggestion = null; + _activeSuggestion = null; + } +} + +class _AutoFormatLinks extends AutoFormat { + static final _urlRegex = + RegExp(r'^(.?)((?:https?:\/\/|www\.)[^\s/$.?#].[^\s]*)'); + const _AutoFormatLinks(); + + @override + Delta? apply(Delta document, int index, String data) { + // This rule applies to a space or newline inserted after a link, so we can ignore + // everything else. + if (data != ' ' && data != '\n') return null; + + final iter = DeltaIterator(document); + final previous = iter.skip(index); + // No previous operation means nothing to analyze. + if (previous == null || previous.data is! String) return null; + final previousText = previous.data as String; + + // Split text of previous operation in lines and words and take the last + // word to test. + final candidate = previousText.split('\n').last.split(' ').last; + try { + final match = _urlRegex.firstMatch(candidate); + if (match == null) return null; + + final attributes = previous.attributes ?? {}; + + // Do nothing if already formatted as link. + if (attributes.containsKey(ParchmentAttribute.link.key)) return null; + + String url = candidate; + if (!url.startsWith('http')) url = 'https://$url'; + attributes + .addAll(ParchmentAttribute.link.fromString(url.toString()).toJson()); + + return Delta() + ..retain(index - candidate.length) + ..retain(candidate.length, attributes); + } on FormatException { + return null; // Our candidate is not a link. + } + } +} diff --git a/packages/fleather/lib/src/widgets/controller.dart b/packages/fleather/lib/src/widgets/controller.dart index b603c6d1..557897f0 100644 --- a/packages/fleather/lib/src/widgets/controller.dart +++ b/packages/fleather/lib/src/widgets/controller.dart @@ -2,12 +2,15 @@ import 'dart:async'; import 'dart:math' as math; import 'package:collection/collection.dart'; -import 'package:fleather/src/widgets/history.dart'; import 'package:fleather/util.dart'; import 'package:flutter/cupertino.dart'; import 'package:parchment/parchment.dart'; import 'package:quill_delta/quill_delta.dart'; +import '../util.dart'; +import 'history.dart'; +import 'autoformats.dart'; + /// List of style keys which can be toggled for insertion List _insertionToggleableStyleKeys = [ ParchmentAttribute.bold.key, @@ -21,6 +24,7 @@ class FleatherController extends ChangeNotifier { FleatherController([ParchmentDocument? document]) : document = document ?? ParchmentDocument(), _history = HistoryStack.doc(document), + _autoFormats = AutoFormats.fallback(), _selection = const TextSelection.collapsed(offset: 0) { _throttledPush = _throttle( duration: throttleDuration, @@ -37,6 +41,9 @@ class FleatherController extends ChangeNotifier { late final _Throttled _throttledPush; Timer? _throttleTimer; + // The autoformat handler + final AutoFormats _autoFormats; + /// Currently selected text within the [document]. TextSelection get selection => _selection; TextSelection _selection; @@ -105,7 +112,7 @@ class FleatherController extends ChangeNotifier { } /// Replaces [length] characters in the document starting at [index] with - /// provided [text]. + /// provided [data]. /// /// Resulting change is registered as produced by user action, e.g. /// using [ChangeSource.local]. @@ -120,6 +127,9 @@ class FleatherController extends ChangeNotifier { Delta? delta; final isDataNotEmpty = data is String ? data.isNotEmpty : true; + + captureAutoFormatCancelationOrUndo(document, index, length, data); + if (length > 0 || isDataNotEmpty) { delta = document.replace(index, length, data); if (_shouldApplyToggledStyles(delta)) { @@ -151,11 +161,32 @@ class FleatherController extends ChangeNotifier { // Only update history when text is being updated // We do not want to update it when selection is changed _updateHistory(); + _autoFormats.run(document, index, data); } } notifyListeners(); } + // Capture auto format cancelation + // Returns `true` is auto format undo is performed + bool captureAutoFormatCancelationOrUndo( + ParchmentDocument document, int position, int length, Object data) { + if (!_autoFormats.hasActiveSuggestion) return false; + + final isDeletionOfOneChar = data is String && data.isEmpty && length == 1; + if (isDeletionOfOneChar) { + // Undo if deleting 1 character after retain of autoformat + if (position == _autoFormats.activeSuggestion!.textLength) { + _autoFormats.undoActive(document); + return true; + } + } + + // Cancel active nevertheless + _autoFormats.cancelActive(); + return false; + } + void formatText(int index, int length, ParchmentAttribute attribute) { final change = document.format(index, length, attribute); // _lastChangeSource = ChangeSource.local; diff --git a/packages/fleather/lib/src/widgets/history.dart b/packages/fleather/lib/src/widgets/history.dart index a387f38a..da72d0e1 100644 --- a/packages/fleather/lib/src/widgets/history.dart +++ b/packages/fleather/lib/src/widgets/history.dart @@ -1,6 +1,7 @@ import 'package:fleather/fleather.dart'; import 'package:flutter/widgets.dart'; import 'package:quill_delta/quill_delta.dart'; +import '../util.dart'; /// Provides undo/redo capabilities for text editing. /// @@ -200,17 +201,3 @@ class _Change { final Delta undoDelta; final Delta redoDelta; } - -extension on Delta { - int get textLength { - int length = 0; - toList().forEach((op) { - if (op.isDelete) { - length -= op.length; - } else { - length += op.length; - } - }); - return length; - } -} diff --git a/packages/parchment/lib/src/heuristics.dart b/packages/parchment/lib/src/heuristics.dart index 1c8636ca..1a9f8a20 100644 --- a/packages/parchment/lib/src/heuristics.dart +++ b/packages/parchment/lib/src/heuristics.dart @@ -31,13 +31,10 @@ class ParchmentHeuristics { // Blocks AutoExitBlockRule(), // must go first PreserveBlockStyleOnInsertRule(), - MarkdownBlockShortcutsInsertRule(), // Lines PreserveLineStyleOnSplitRule(), ResetLineFormatOnNewLineRule(), - AutoTextDirectionRule(), // Inlines - AutoFormatLinksRule(), PreserveInlineStylesRule(), // Catch-all CatchAllInsertRule(), diff --git a/packages/parchment/lib/src/heuristics/insert_rules.dart b/packages/parchment/lib/src/heuristics/insert_rules.dart index 81bc2fb8..5f2acc47 100644 --- a/packages/parchment/lib/src/heuristics/insert_rules.dart +++ b/packages/parchment/lib/src/heuristics/insert_rules.dart @@ -277,61 +277,6 @@ class PreserveInlineStylesRule extends InsertRule { } } -/// Applies link format to text segment (which looks like a link) when user -/// inserts space character after it. -class AutoFormatLinksRule extends InsertRule { - const AutoFormatLinksRule(); - - static Delta? formatLink(Delta document, int index, Object data) { - if (data is! String) return null; - - // This rule applies to a space or newline inserted after a link, so we can ignore - // everything else. - if (data != ' ' && data != '\n') return null; - - final iter = DeltaIterator(document); - final previous = iter.skip(index); - // No previous operation means nothing to analyze. - if (previous == null || previous.data is! String) return null; - final previousText = previous.data as String; - - // Split text of previous operation in lines and words and take the last - // word to test. - final candidate = previousText.split('\n').last.split(' ').last; - try { - final link = Uri.parse(candidate); - if (!['https', 'http'].contains(link.scheme)) { - // TODO: might need a more robust way of validating links here. - return null; - } - final attributes = previous.attributes ?? {}; - - // Do nothing if already formatted as link. - if (attributes.containsKey(ParchmentAttribute.link.key)) return null; - - attributes - .addAll(ParchmentAttribute.link.fromString(link.toString()).toJson()); - return Delta() - ..retain(index - candidate.length) - ..retain(candidate.length, attributes); - } on FormatException { - return null; // Our candidate is not a link. - } - } - - @override - Delta? apply(Delta document, int index, Object data) { - final delta = formatLink(document, index, data); - if (delta == null) { - return null; - } - - final iter = DeltaIterator(document); - final previous = iter.skip(index); - return delta..insert(data, previous?.attributes); - } -} - /// Forces text inserted on the same line with a block embed (before or after it) /// to be moved to a new line adjacent to the original line. /// @@ -426,9 +371,7 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { // Go over each inserted line and ensure block style is applied. final lines = data.split('\n'); - // Try to format link after hitting newline - final linkDelta = AutoFormatLinksRule.formatLink(document, index, data); - final result = linkDelta ?? (Delta()..retain(index)); + final result = Delta()..retain(index); for (var i = 0; i < lines.length; i++) { final line = lines[i]; diff --git a/packages/parchment/test/heuristics/insert_rules_test.dart b/packages/parchment/test/heuristics/insert_rules_test.dart index cabb0da5..668ce653 100644 --- a/packages/parchment/test/heuristics/insert_rules_test.dart +++ b/packages/parchment/test/heuristics/insert_rules_test.dart @@ -198,56 +198,6 @@ void main() { }); }); - group('$AutoFormatLinksRule', () { - final rule = AutoFormatLinksRule(); - final link = - ParchmentAttribute.link.fromString('https://example.com').toJson(); - - test('apply simple', () { - final doc = Delta()..insert('Doc with link https://example.com'); - final actual = rule.apply(doc, 33, ' '); - final expected = Delta() - ..retain(14) - ..retain(19, link) - ..insert(' '); - expect(expected, actual); - }); - - test('apply simple newline', () { - final doc = Delta()..insert('Doc with link https://example.com'); - final actual = rule.apply(doc, 33, '\n'); - final expected = Delta() - ..retain(14) - ..retain(19, link) - ..insert('\n'); - expect(expected, actual); - }); - - test('applies only to insert of single space', () { - final doc = Delta()..insert('Doc with link https://example.com'); - final actual = rule.apply(doc, 33, '/'); - expect(actual, isNull); - }); - - test('applies for links at the beginning of line', () { - final doc = Delta()..insert('Doc with link\nhttps://example.com'); - final actual = rule.apply(doc, 33, ' '); - final expected = Delta() - ..retain(14) - ..retain(19, link) - ..insert(' '); - expect(expected, actual); - }); - - test('ignores if already formatted as link', () { - final doc = Delta() - ..insert('Doc with link\n') - ..insert('https://example.com', link); - final actual = rule.apply(doc, 33, ' '); - expect(actual, isNull); - }); - }); - group('$PreserveBlockStyleOnInsertRule', () { final rule = PreserveBlockStyleOnInsertRule();