Skip to content

Commit

Permalink
Adapt to support Markdown shortcuts
Browse files Browse the repository at this point in the history
Added support for direction detection
  • Loading branch information
amantoux committed Dec 3, 2023
1 parent fb34176 commit a0df08c
Show file tree
Hide file tree
Showing 6 changed files with 323 additions and 319 deletions.
321 changes: 294 additions & 27 deletions packages/fleather/lib/src/widgets/autoformats.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
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 back for a pattern and apply a
Expand All @@ -9,75 +13,111 @@ import 'package:quill_delta/quill_delta.dart';
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);
/// Indicates whether character trigger auto format is kept in document
///
/// E.g: for link detections, '[space]' is kept whereas for Markdown block
/// shortcuts, the '[space]' is not added to document
bool get keepTriggerCharacter;

/// Upon upon insertion of a space or new line run format detection and appy
/// formatting to document
/// Returns a [ActiveFormatResult].
ActiveSuggestion? apply(
ParchmentDocument document, int position, String data);
}

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

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

final List<AutoFormat> _autoFormats;

Delta? get activeSuggestion => _activeSuggestion;
Delta? _activeSuggestion;
Delta? _undoActiveSuggestion;
ActiveSuggestion? _activeSuggestion;

Delta get activeSuggestionChange => _activeSuggestion!.change;

Check warning on line 47 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L47

Added line #L47 was not covered by tests

bool get activeSuggestionKeepTriggerCharacter =>
_activeSuggestion!.keepTriggerCharacter;

Check warning on line 50 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L49-L50

Added lines #L49 - L50 were not covered by tests

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;
TextSelection? run(ParchmentDocument document, int position, Object data) {
if (data is! String || data.isEmpty) {
return null;
}

Delta documentDelta = document.toDelta();
for (final autoFormat in _autoFormats) {
_activeSuggestion = autoFormat.apply(documentDelta, position, data)
?..trim();
_activeSuggestion = autoFormat.apply(document, position, data);
if (_activeSuggestion != null) {
_undoActiveSuggestion = _activeSuggestion!.invert(documentDelta);
document.compose(_activeSuggestion!, ChangeSource.local);
return;
return _activeSuggestion!.selection;

Check warning on line 65 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L65

Added line #L65 was not covered by tests
}
}
return null;
}

/// 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;
/// It will throw if [_activeSuggestion] is null.
TextSelection undoActive(ParchmentDocument document) {
final undoSelection = _activeSuggestion!.undoSelection;
document.compose(_activeSuggestion!.undo, ChangeSource.local);
_activeSuggestion = null;

Check warning on line 76 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L73-L76

Added lines #L73 - L76 were not covered by tests
return undoSelection;
}

/// Cancel active auto format
void cancelActive() {
_undoActiveSuggestion = null;
_activeSuggestion = null;

Check warning on line 82 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L81-L82

Added lines #L81 - L82 were not covered by tests
}
}

class ActiveSuggestion {
ActiveSuggestion({

Check warning on line 87 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L87

Added line #L87 was not covered by tests
required this.selection,
required this.change,
required this.undoSelection,
required this.undo,
required this.keepTriggerCharacter,
});

final TextSelection selection;
final Delta change;
final TextSelection undoSelection;
final Delta undo;
final bool keepTriggerCharacter;
}

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

Check warning on line 104 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L103-L104

Added lines #L103 - L104 were not covered by tests

const _AutoFormatLinks();

@override
Delta? apply(Delta document, int index, String data) {
final bool keepTriggerCharacter = true;

@override
ActiveSuggestion? 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 iter = DeltaIterator(document);
final previous = iter.skip(index);
final documentDelta = document.toDelta();
final iter = DeltaIterator(documentDelta);
final previous = iter.skip(position);

Check warning on line 120 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L118-L120

Added lines #L118 - L120 were not covered by tests
// No previous operation means nothing to analyze.
if (previous == null || previous.data is! String) return null;
final previousText = previous.data as String;

Check warning on line 123 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L122-L123

Added lines #L122 - L123 were not covered by tests
Expand All @@ -99,11 +139,238 @@ class _AutoFormatLinks extends AutoFormat {
attributes
.addAll(ParchmentAttribute.link.fromString(url.toString()).toJson());

Check warning on line 140 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L140

Added line #L140 was not covered by tests

return Delta()
..retain(index - candidate.length)
final change = Delta()
..retain(position - candidate.length)
..retain(candidate.length, attributes);
final undo = change.invert(documentDelta);
document.compose(change, ChangeSource.local);
return ActiveSuggestion(
selection: TextSelection.collapsed(offset: position + 1),

Check warning on line 148 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L142-L148

Added lines #L142 - L148 were not covered by tests
change: change,
undo: undo,
undoSelection: TextSelection.collapsed(offset: position),
keepTriggerCharacter: keepTriggerCharacter);
} on FormatException {

Check warning on line 153 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L151-L153

Added lines #L151 - L153 were not covered by tests
return null; // Our candidate is not a link.
}
}
}

/// 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,

Check warning on line 171 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L161-L171

Added lines #L161 - L171 were not covered by tests
};

const _MarkdownShortCuts();

@override
final bool keepTriggerCharacter = false;

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

Check warning on line 181 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L179-L181

Added lines #L179 - L181 were not covered by tests

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

Check warning on line 183 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L183

Added line #L183 was not covered by tests
}

(TextSelection, Delta)? _formatLine(

Check warning on line 186 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L186

Added line #L186 was not covered by tests
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 */);

Check warning on line 191 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L189-L191

Added lines #L189 - L191 were not covered by tests

int cursorPosition = index - prefix.length;

Check warning on line 193 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L193

Added line #L193 was not covered by tests

// 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;

Check warning on line 200 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L196-L200

Added lines #L196 - L200 were not covered by tests
continue;
}

final text = op.data as String;

Check warning on line 204 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L204

Added line #L204 was not covered by tests
// text starts with the inserted '[space]' that trigger the shortcut
final pos = text.indexOf('\n') - 1;

Check warning on line 206 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L206

Added line #L206 was not covered by tests

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

Check warning on line 210 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L208-L210

Added lines #L208 - L210 were not covered by tests
continue;
}

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

Check warning on line 215 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L214-L215

Added lines #L214 - L215 were not covered by tests

final attrs = <String, dynamic>{};
final currentLineAttrs = op.attributes;

Check warning on line 218 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L217-L218

Added lines #L217 - L218 were not covered by tests
if (currentLineAttrs != null) {
// the attribute already exists abort
if (currentLineAttrs[attr.key] == attr.value) return null;
attrs.addAll(currentLineAttrs);

Check warning on line 222 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L221-L222

Added lines #L221 - L222 were not covered by tests
}
attrs.addAll(attr.toJson());

Check warning on line 224 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L224

Added line #L224 was not covered by tests

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

Check warning on line 227 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L227

Added line #L227 was not covered by tests

break;
}

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

Check warning on line 232 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L232

Added line #L232 was not covered by tests
}

@override
ActiveSuggestion? apply(ParchmentDocument document, int index, 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, index);
if (prefix == null || prefix.isEmpty) return null;
final shortcut = '$prefix$data';
if (shortcut == '```' || shortcut == "'''") {

Check warning on line 245 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L240-L245

Added lines #L240 - L245 were not covered by tests
final result =
_formatLine(iter, index, prefix, ParchmentAttribute.code);

Check warning on line 247 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L247

Added line #L247 was not covered by tests
if (result == null) return null;
final change = result.$2;
final undo = change.invert(documentDelta);
document.compose(change, ChangeSource.local);
return ActiveSuggestion(

Check warning on line 252 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L250-L252

Added lines #L250 - L252 were not covered by tests
selection: result.$1,
change: change,
undoSelection: TextSelection.collapsed(offset: index + data.length),

Check warning on line 255 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L255

Added line #L255 was not covered by tests
undo: undo,
keepTriggerCharacter: keepTriggerCharacter);

Check warning on line 257 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L257

Added line #L257 was not covered by tests
}
}

// 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, index);

Check warning on line 266 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L264-L266

Added lines #L264 - L266 were not covered by tests

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

Check warning on line 268 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L268

Added line #L268 was not covered by tests

final attribute = rules[prefix];

Check warning on line 270 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L270

Added line #L270 was not covered by tests
if (attribute == null) return null;

final result = _formatLine(iter, index, prefix, attribute);

Check warning on line 273 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L273

Added line #L273 was not covered by tests
if (result == null) return null;
final change = result.$2;
final undo = change.invert(documentDelta);
document.compose(change, ChangeSource.local);
return ActiveSuggestion(

Check warning on line 278 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L276-L278

Added lines #L276 - L278 were not covered by tests
selection: result.$1,
change: change,
undoSelection: TextSelection.collapsed(offset: index + 1),

Check warning on line 281 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L281

Added line #L281 was not covered by tests
undo: undo,
keepTriggerCharacter: keepTriggerCharacter);

Check warning on line 283 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L283

Added line #L283 was not covered by tests
}
}

/// 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);

Check warning on line 291 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L289-L291

Added lines #L289 - L291 were not covered by tests
}

final prefix = <Operation>[];

Check warning on line 294 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L294

Added line #L294 was not covered by tests

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);

Check warning on line 302 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L297-L302

Added lines #L297 - L302 were not covered by tests
} else {
var text = op.data as String;
var pos = text.lastIndexOf('\n');
if (pos == -1) {
prefix.add(op);

Check warning on line 307 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L304-L307

Added lines #L304 - L307 were not covered by tests
} else {
prefix.clear();
prefix.add(Operation.insert(text.substring(pos + 1), op.attributes));

Check warning on line 310 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L309-L310

Added lines #L309 - L310 were not covered by tests
}
}
skipped += op.length;

Check warning on line 313 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L313

Added line #L313 was not covered by tests
}
return prefix;
}

/// 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;

@override
final bool keepTriggerCharacter = true;

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

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

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

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

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

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

Check warning on line 355 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L352-L355

Added lines #L352 - L355 were not covered by tests
};
} else {
attributes = {
...ParchmentAttribute.rtl.unset.toJson(),
...ParchmentAttribute.alignment.unset.toJson(),

Check warning on line 360 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L358-L360

Added lines #L358 - L360 were not covered by tests
};
}

final change = Delta()
..retain(index)
..insert(data)
..retain(1, attributes);
final undo = change.invert(documentDelta);
return ActiveSuggestion(
selection: TextSelection.collapsed(offset: index + data.length),

Check warning on line 370 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L364-L370

Added lines #L364 - L370 were not covered by tests
change: change,
undoSelection: TextSelection.collapsed(offset: index),

Check warning on line 372 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L372

Added line #L372 was not covered by tests
undo: undo,
keepTriggerCharacter: keepTriggerCharacter);

Check warning on line 374 in packages/fleather/lib/src/widgets/autoformats.dart

View check run for this annotation

Codecov / codecov/patch

packages/fleather/lib/src/widgets/autoformats.dart#L374

Added line #L374 was not covered by tests
}
}
Loading

0 comments on commit a0df08c

Please sign in to comment.