Skip to content

Commit

Permalink
Auto formats (#181)
Browse files Browse the repository at this point in the history
  • Loading branch information
amantoux authored Dec 10, 2023
1 parent 00b14a0 commit 4c3e95f
Show file tree
Hide file tree
Showing 13 changed files with 729 additions and 458 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;
}
}
376 changes: 376 additions & 0 deletions packages/fleather/lib/src/widgets/autoformats.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,376 @@
import 'dart:math' as math;

import 'package:fleather/fleather.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart' as intl;
import 'package:quill_delta/quill_delta.dart';

/// An [AutoFormat] is responsible for looking backwards for a pattern and
/// applying a formatting suggestion to a document.
///
/// For example, identifying a link and automatically wrapping it with a link
/// attribute or applying block formats using Markdown shortcuts
///
/// TODO: adapt to support changes made by a remote source
abstract class AutoFormat {
const AutoFormat();

/// Upon insertion of a trigger character, run format detection and apply
/// formatting to document
///
/// Returns a [AutoFormatResult].
AutoFormatResult? apply(
ParchmentDocument document, int position, String data);
}

/// Registry for [AutoFormats].
class AutoFormats {
AutoFormats({required List<AutoFormat> autoFormats})
: _autoFormats = autoFormats;

/// Default set of auto formats.
factory AutoFormats.fallback() {
return AutoFormats(autoFormats: [
const _AutoFormatLinks(),
const _MarkdownShortCuts(),
const _AutoTextDirection(),
]);
}

final List<AutoFormat> _autoFormats;

AutoFormatResult? _activeSuggestion;

/// The selection override of the active formatting suggestion
TextSelection? get selection => _activeSuggestion!.selection;

/// The position at which the active suggestion can be deactivated
int get undoPosition => _activeSuggestion!.undoPositionCandidate;

/// `true` if there is an active suggestion; `false` otherwise
bool get hasActiveSuggestion => _activeSuggestion != null;

/// Perform detection of auto formats and apply changes to [document].
///
/// Inserted data must be of type [String].
///
/// Returns `true` if auto format was activated; `false` otherwise
bool run(ParchmentDocument document, int position, Object data) {
if (data is! String || data.isEmpty) {
return false;
}

for (final autoFormat in _autoFormats) {
_activeSuggestion = autoFormat.apply(document, position, data);
if (_activeSuggestion != null) {
return true;
}
}
return false;
}

/// Remove auto format from [document] and de-activate current suggestion.
///
/// This will throw if [_activeSuggestion] is null.
TextSelection? undoActive(ParchmentDocument document) {
final undoSelection = _activeSuggestion!.undoSelection;
document.compose(_activeSuggestion!.undo, ChangeSource.local);
_activeSuggestion = null;
return undoSelection;
}

/// Cancel active suggestion
void cancelActive() {
_activeSuggestion = null;
}
}

/// The result of a [AutoFormat.apply] that has detected a pattern
class AutoFormatResult {
AutoFormatResult({
this.selection,
required this.change,
this.undoSelection,
required this.undo,
required this.undoPositionCandidate,
});

/// *Optional* [TextSelection] after applying the auto format.
///
/// Useful for Markdown shortcuts for example
final TextSelection? selection;

/// The change that was applied
final Delta change;

/// *Optional* [TextSelection] after undoing the formatting
///
/// Useful for Markdown shortcuts for example
final TextSelection? undoSelection;

/// The changes to undo the formatting
final Delta undo;

/// The position at which an auto format can be canceled
final int undoPositionCandidate;
}

class _AutoFormatLinks extends AutoFormat {
static final _urlRegex =
RegExp(r'^(.?)((?:https?://|www\.)[^\s/$.?#].[^\s]*)');

const _AutoFormatLinks();

@override
AutoFormatResult? apply(
ParchmentDocument document, int position, 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 documentDelta = document.toDelta();
final iter = DeltaIterator(documentDelta);
final previous = iter.skip(position);
// 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;
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());

final change = Delta()
..retain(position - candidate.length)
..retain(candidate.length, attributes);
final undo = change.invert(documentDelta);
document.compose(change, ChangeSource.local);
return AutoFormatResult(
change: change, undo: undo, undoPositionCandidate: position);
}
}

// Replaces certain Markdown shortcuts with actual line or block styles.
class _MarkdownShortCuts extends AutoFormat {
static final rules = <String, ParchmentAttribute>{
'-': ParchmentAttribute.block.bulletList,
'*': ParchmentAttribute.block.bulletList,
'1.': ParchmentAttribute.block.numberList,
'[]': ParchmentAttribute.block.checkList,
"'''": ParchmentAttribute.block.code,
'```': ParchmentAttribute.block.code,
'>': ParchmentAttribute.block.quote,
'#': ParchmentAttribute.h1,
'##': ParchmentAttribute.h2,
'###': ParchmentAttribute.h3,
};

const _MarkdownShortCuts();

String? _getLinePrefix(DeltaIterator iter, int index) {
final prefixOps = skipToLineAt(iter, index);
if (prefixOps.any((element) => element.data is! String)) return null;

return prefixOps.map((e) => e.data).cast<String>().join();
}

// Skips to the beginning of line containing position at specified [length]
// and returns contents of the line skipped so far.
List<Operation> skipToLineAt(DeltaIterator iter, int length) {
if (length == 0) {
return List.empty(growable: false);
}

final prefix = <Operation>[];

var skipped = 0;
while (skipped < length && iter.hasNext) {
final opLength = iter.peekLength();
final skip = math.min(length - skipped, opLength);
final op = iter.next(skip);
if (op.data is! String) {
prefix.add(op);
} else {
var text = op.data as String;
var pos = text.lastIndexOf('\n');
if (pos == -1) {
prefix.add(op);
} else {
prefix.clear();
prefix.add(Operation.insert(text.substring(pos + 1), op.attributes));
}
}
skipped += op.length;
}
return prefix;
}

(TextSelection, Delta)? _formatLine(
DeltaIterator iter, int index, String prefix, ParchmentAttribute attr) {
/// First, delete the shortcut prefix itself.
final result = Delta()
..retain(index - prefix.length)
..delete(prefix.length + 1 /* '[space]' has been added */);
// Go after added [space] that triggers shortcut detection
iter.skip(1);

int cursorPosition = index - prefix.length;

// Scan to the end of line to apply the style attribute.
while (iter.hasNext) {
final op = iter.next();
if (op.data is! String) {
result.retain(op.length);
cursorPosition += op.length;
continue;
}

final text = op.data as String;
final pos = text.indexOf('\n');

if (pos <= -1) {
result.retain(op.length);
cursorPosition += op.length;
continue;
}

result.retain(pos);
cursorPosition += pos;

final attrs = <String, dynamic>{};
final currentLineAttrs = op.attributes;
if (currentLineAttrs != null) {
// the attribute already exists abort
if (currentLineAttrs[attr.key] == attr.value) return null;
attrs.addAll(currentLineAttrs);
}
attrs.addAll(attr.toJson());

// cursor should be placed before new line feed
result.retain(1, attrs);

break;
}

return (TextSelection.collapsed(offset: cursorPosition), result);
}

@override
AutoFormatResult? apply(
ParchmentDocument document, int position, String data) {
// Special case: code blocks don't need a `space` to get formatted, we can
// detect when the user types ``` (or ''') and apply the style immediately.
if (data == '`' || data == "'") {
final documentDelta = document.toDelta();
final iter = DeltaIterator(documentDelta);
final prefix = _getLinePrefix(iter, position);
if (prefix == null || prefix.isEmpty) return null;
final shortcut = '$prefix$data';
if (shortcut == '```' || shortcut == "'''") {
final result =
_formatLine(iter, position, prefix, ParchmentAttribute.code);
if (result == null) return null;
final change = result.$2;
final undo = change.invert(documentDelta);
document.compose(change, ChangeSource.local);
return AutoFormatResult(
selection: result.$1,
change: change,
undoSelection:
TextSelection.collapsed(offset: position + data.length),
undo: undo,
undoPositionCandidate: position - prefix.length - 1);
}
}

// Standard case: triggered by a space character after the shortcut.
if (data != ' ') return null;

final documentDelta = document.toDelta();
final iter = DeltaIterator(documentDelta);
final prefix = _getLinePrefix(iter, position);

if (prefix == null || prefix.isEmpty) return null;

final attribute = rules[prefix];
if (attribute == null) return null;

final result = _formatLine(iter, position, prefix, attribute);
if (result == null) return null;
final change = result.$2;
final undo = change.invert(documentDelta);
document.compose(change, ChangeSource.local);
return AutoFormatResult(
selection: result.$1,
change: change,
// current position is after prefix, so need to add 1 for space
undoSelection: TextSelection.collapsed(offset: position + 1),
undo: undo,
undoPositionCandidate: position - prefix.length - 1);
}
}

// Infers text direction from the input when happens in the beginning of a line.
// This rule also removes alignment and sets it based on inferred direction.
class _AutoTextDirection extends AutoFormat {
const _AutoTextDirection();

final _isRTL = intl.Bidi.startsWithRtl;

bool _isAfterEmptyLine(Operation? previous) {
final data = previous?.data;
return data == null || (data is String ? data.endsWith('\n') : false);
}

bool _isBeforeEmptyLine(Operation next, String data) {
final nextData = next.data;
return nextData is String ? nextData.startsWith('$data\n') : false;
}

bool _isInEmptyLine(Operation? previous, Operation next, String data) =>
_isAfterEmptyLine(previous) && _isBeforeEmptyLine(next, data);

@override
AutoFormatResult? apply(
ParchmentDocument document, int position, String data) {
if (data == '\n') return null;
final documentDelta = document.toDelta();
final iter = DeltaIterator(document.toDelta());
final previous = iter.skip(position);
final next = iter.next();

if (!_isInEmptyLine(previous, next, data)) return null;

final Map<String, dynamic> attributes;
if (_isRTL(data)) {
attributes = {
...ParchmentAttribute.rtl.toJson(),
...ParchmentAttribute.alignment.right.toJson(),
};
} else {
attributes = {
...ParchmentAttribute.rtl.unset.toJson(),
...ParchmentAttribute.alignment.unset.toJson(),
};
}

final change = Delta()
..retain(position + data.length) //
..retain(1, attributes);
final undo = change.invert(documentDelta);
document.compose(change, ChangeSource.local);
return AutoFormatResult(
change: change, undo: undo, undoPositionCandidate: position);
}
}
Loading

0 comments on commit 4c3e95f

Please sign in to comment.