Skip to content

Commit

Permalink
Auto formats and auto link implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
amantoux committed Dec 3, 2023
1 parent 23422a2 commit 6f06de9
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 77 deletions.
1 change: 1 addition & 0 deletions packages/fleather/lib/fleather.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
15 changes: 15 additions & 0 deletions packages/fleather/lib/src/util.dart
Original file line number Diff line number Diff line change
@@ -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;
}
}
109 changes: 109 additions & 0 deletions packages/fleather/lib/src/widgets/autoformats.dart
Original file line number Diff line number Diff line change
@@ -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<AutoFormat> autoFormats})
: _autoFormats = autoFormats;

/// Default set of autoformats.
factory AutoFormats.fallback() {
return AutoFormats(autoFormats: [const _AutoFormatLinks()]);
}

final List<AutoFormat> _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 ?? <String, dynamic>{};

// 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.
}
}
}
35 changes: 33 additions & 2 deletions packages/fleather/lib/src/widgets/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _insertionToggleableStyleKeys = [
ParchmentAttribute.bold.key,
Expand All @@ -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,
Expand All @@ -37,6 +41,9 @@ class FleatherController extends ChangeNotifier {
late final _Throttled<Delta> _throttledPush;
Timer? _throttleTimer;

// The autoformat handler
final AutoFormats _autoFormats;

/// Currently selected text within the [document].
TextSelection get selection => _selection;
TextSelection _selection;
Expand Down Expand Up @@ -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].
Expand All @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 1 addition & 14 deletions packages/fleather/lib/src/widgets/history.dart
Original file line number Diff line number Diff line change
@@ -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.
///
Expand Down Expand Up @@ -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;
}
}
3 changes: 0 additions & 3 deletions packages/parchment/lib/src/heuristics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,10 @@ class ParchmentHeuristics {
// Blocks
AutoExitBlockRule(), // must go first
PreserveBlockStyleOnInsertRule(),
MarkdownBlockShortcutsInsertRule(),
// Lines
PreserveLineStyleOnSplitRule(),
ResetLineFormatOnNewLineRule(),
AutoTextDirectionRule(),
// Inlines
AutoFormatLinksRule(),
PreserveInlineStylesRule(),
// Catch-all
CatchAllInsertRule(),
Expand Down
59 changes: 1 addition & 58 deletions packages/parchment/lib/src/heuristics/insert_rules.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? <String, dynamic>{};

// 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.
///
Expand Down Expand Up @@ -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];
Expand Down

0 comments on commit 6f06de9

Please sign in to comment.