diff --git a/packages/notus/CHANGELOG.md b/packages/notus/CHANGELOG.md index f5c9f7de5..0045ada41 100644 --- a/packages/notus/CHANGELOG.md +++ b/packages/notus/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.0-dev.2.0 + +* Improved block-level heuristics to not exit when adding empty lines in the middle of a block. + ## 1.0.0-dev.1.0 This is the first dev version of the notus package for the upcoming 1.0.0 release. diff --git a/packages/notus/lib/src/heuristics.dart b/packages/notus/lib/src/heuristics.dart index 8e369aa23..1b5356b3e 100644 --- a/packages/notus/lib/src/heuristics.dart +++ b/packages/notus/lib/src/heuristics.dart @@ -24,14 +24,19 @@ class NotusHeuristics { // attributes. ], insertRules: [ + // Embeds InsertEmbedsRule(), - PreserveBlockStyleOnPasteRule(), ForceNewlineForInsertsAroundEmbedRule(), + // Blocks + AutoExitBlockRule(), // must go first + PreserveBlockStyleOnInsertRule(), + // Lines PreserveLineStyleOnSplitRule(), - AutoExitBlockRule(), ResetLineFormatOnNewLineRule(), + // Inlines AutoFormatLinksRule(), PreserveInlineStylesRule(), + // Catch-all CatchAllInsertRule(), ], deleteRules: [ diff --git a/packages/notus/lib/src/heuristics/insert_rules.dart b/packages/notus/lib/src/heuristics/insert_rules.dart index fb041fa29..2aada40a5 100644 --- a/packages/notus/lib/src/heuristics/insert_rules.dart +++ b/packages/notus/lib/src/heuristics/insert_rules.dart @@ -6,6 +6,40 @@ import 'package:notus/notus.dart'; import 'package:notus/src/document/embeds.dart'; import 'package:quill_delta/quill_delta.dart'; +/// The result of [_findNextNewline] function. +class _FindResult { + /// The operation containing a newline character, can be null. + final Operation op; + + /// Total length of skipped characters before [op]. + final int skippedLength; + + _FindResult(this.op, this.skippedLength); + + /// If true then no operation containing newline was found. + bool get isEmpty => op == null; + + /// If false then no operation containing newline was found. + bool get isNotEmpty => op != null; +} + +/// Finds closest operation containing a newline character from current +/// position of [iterator]. +_FindResult _findNextNewline(DeltaIterator iterator) { + var skipped = 0; + while (iterator.hasNext) { + final op = iterator.next(); + final opText = op.data is String ? op.data as String : ''; + final lf = opText.indexOf('\n'); + if (lf >= 0) { + return _FindResult(op, skipped); + } else { + skipped += op.length; + } + } + return _FindResult(null, null); +} + /// A heuristic rule for insert operations. abstract class InsertRule { /// Constant constructor allows subclasses to declare constant constructors. @@ -69,17 +103,8 @@ class PreserveLineStyleOnSplitRule extends InsertRule { return result; } // Continue looking for a newline. - Map attributes; - while (iter.hasNext) { - final op = iter.next(); - if (op.data is! String) continue; // not interested in embeds. - final opText = op.data as String; - final lf = opText.indexOf('\n'); - if (lf != -1) { - attributes = op.attributes; - break; - } - } + final nextNewline = _findNextNewline(iter); + final attributes = nextNewline?.op?.attributes; return result..insert('\n', attributes); } @@ -127,7 +152,10 @@ class ResetLineFormatOnNewLineRule extends InsertRule { /// Heuristic rule to exit current block when user inserts two consecutive /// newlines. -// TODO: update this rule to handle code blocks differently, at least allow 3 consecutive newlines before exiting. +/// +/// This rule is only applied when the cursor is on the last line of a block. +/// When the cursor is in the middle of a block we allow adding empty lines +/// and preserving the block's style. class AutoExitBlockRule extends InsertRule { const AutoExitBlockRule(); @@ -150,20 +178,42 @@ class AutoExitBlockRule extends InsertRule { final target = iter.next(); final isInBlock = target.isNotPlain && target.attributes.containsKey(NotusAttribute.block.key); - if (isEmptyLine(previous, target) && isInBlock) { - // We reset block style even if this line is not the last one in it's - // block which effectively splits the block into two. - // TODO: For code blocks this should not split the block but allow inserting as many lines as needed. - var attributes; - if (target.attributes != null) { - attributes = target.attributes; - } else { - attributes = {}; - } - attributes.addAll(NotusAttribute.block.unset.toJson()); - return Delta()..retain(index)..retain(1, attributes); + + // We are not in a block, ignore. + if (!isInBlock) return null; + // We are not on an empty line, ignore. + if (!isEmptyLine(previous, target)) return null; + + final blockStyle = target.attributes[NotusAttribute.block.key]; + + // We are on an empty line. Now we need to determine if we are on the + // last line of a block. + // First check if `target` length is greater than 1, this would indicate + // that it contains multiple newline characters which share the same style. + // This would mean we are not on the last line yet. + final targetText = target.value + as String; // this is safe since we already called isEmptyLine and know it contains a newline + + if (targetText.length > 1) { + // We are not on the last line of this block, ignore. + return null; } - return null; + + // Keep looking for the next newline character to see if it shares the same + // block style as `target`. + final nextNewline = _findNextNewline(iter); + if (nextNewline.isNotEmpty && + nextNewline.op.attributes != null && + nextNewline.op.attributes[NotusAttribute.block.key] == blockStyle) { + // We are not at the end of this block, ignore. + return null; + } + + // Here we now know that the line after `target` is not in the same block + // therefore we can exit this block. + final attributes = target.attributes ?? {}; + attributes.addAll(NotusAttribute.block.unset.toJson()); + return Delta()..retain(index)..retain(1, attributes); } } @@ -307,10 +357,16 @@ class ForceNewlineForInsertsAroundEmbedRule extends InsertRule { } } -/// Preserves block style when user pastes text containing newlines. +/// Preserves block style when user inserts text containing newlines. +/// +/// This rule handles: +/// +/// * inserting a new line in a block +/// * pasting text containing multiple lines of text in a block +/// /// This rule may also be activated for changes triggered by auto-correct. -class PreserveBlockStyleOnPasteRule extends InsertRule { - const PreserveBlockStyleOnPasteRule(); +class PreserveBlockStyleOnInsertRule extends InsertRule { + const PreserveBlockStyleOnInsertRule(); bool isEdgeLineSplit(Operation before, Operation after) { if (before == null) return true; // split at the beginning of a doc @@ -325,41 +381,34 @@ class PreserveBlockStyleOnPasteRule extends InsertRule { if (data is! String) return null; final text = data as String; - if (!text.contains('\n') || text.length == 1) { - // Only interested in text containing at least one newline and at least - // one more character. + if (!text.contains('\n')) { + // Only interested in text containing at least one newline character. return null; } final iter = DeltaIterator(document); iter.skip(index); - // Look for next newline. - Map lineStyle; - while (iter.hasNext) { - final op = iter.next(); - final opText = op.data is String ? op.data as String : ''; - final lf = opText.indexOf('\n'); - if (lf >= 0) { - lineStyle = op.attributes; - break; - } - } + // Look for the next newline. + final nextNewline = _findNextNewline(iter); + final lineStyle = nextNewline.op?.attributes ?? {}; - Map resetStyle; - Map blockStyle; - if (lineStyle != null) { - if (lineStyle.containsKey(NotusAttribute.heading.key)) { - resetStyle = NotusAttribute.heading.unset.toJson(); - } + // Are we currently in a block? If not then ignore. + if (!lineStyle.containsKey(NotusAttribute.block.key)) return null; - if (lineStyle.containsKey(NotusAttribute.block.key)) { - blockStyle = { - NotusAttribute.block.key: lineStyle[NotusAttribute.block.key] - }; - } + final blockStyle = { + NotusAttribute.block.key: lineStyle[NotusAttribute.block.key] + }; + + Map resetStyle; + // If current line had heading style applied to it we'll need to move this + // style to the newly inserted line before it and reset style of the + // original line. + if (lineStyle.containsKey(NotusAttribute.heading.key)) { + resetStyle = NotusAttribute.heading.unset.toJson(); } + // Go over each inserted line and ensure block style is applied. final lines = text.split('\n'); final result = Delta()..retain(index); for (var i = 0; i < lines.length; i++) { @@ -368,14 +417,22 @@ class PreserveBlockStyleOnPasteRule extends InsertRule { result.insert(line); } if (i == 0) { + // The first line should inherit the lineStyle entirely. result.insert('\n', lineStyle); - } else if (i == lines.length - 1) { - if (resetStyle != null) result.retain(1, resetStyle); - } else { + } else if (i < lines.length - 1) { + // we don't want to insert a newline after the last chunk of text, so -1 result.insert('\n', blockStyle); } } + // Reset style of the original newline character if needed. + if (resetStyle != null) { + result.retain(nextNewline.skippedLength); + final opText = nextNewline.op.data as String; + final lf = opText.indexOf('\n'); + result..retain(lf)..retain(1, resetStyle); + } + return result; } } diff --git a/packages/notus/pubspec.yaml b/packages/notus/pubspec.yaml index ec6900428..55f9c4159 100644 --- a/packages/notus/pubspec.yaml +++ b/packages/notus/pubspec.yaml @@ -1,11 +1,11 @@ name: notus description: Platform-agnostic rich text document model based on Delta format and used in Zefyr editor. -version: 1.0.0-dev.1.0 +version: 1.0.0-dev.2.0 author: Anatoly Pulyaevskiy homepage: https://github.com/memspace/zefyr environment: - sdk: '>=2.2.0 <3.0.0' + sdk: '>=2.9.0 <3.0.0' dependencies: collection: ^1.14.6 diff --git a/packages/notus/test/heuristics/insert_rules_test.dart b/packages/notus/test/heuristics/insert_rules_test.dart index de0a89b45..ab03672a9 100644 --- a/packages/notus/test/heuristics/insert_rules_test.dart +++ b/packages/notus/test/heuristics/insert_rules_test.dart @@ -112,7 +112,8 @@ void main() { group('$AutoExitBlockRule', () { final rule = AutoExitBlockRule(); - test('applies when line-break is inserted on empty line in a block', () { + test('applies when newline is inserted on the last empty line in a block', + () { final ul = NotusAttribute.ul.toJson(); final doc = Delta() ..insert('Item 1') @@ -145,10 +146,17 @@ void main() { test('ignores non-empty line at the beginning of a document', () { final ul = NotusAttribute.ul.toJson(); - final doc = Delta()..insert('Text\n', ul); + final doc = Delta()..insert('Text')..insert('\n', ul); final actual = rule.apply(doc, 0, '\n'); expect(actual, isNull); }); + + test('ignores empty lines in the middle of a block', () { + final ul = NotusAttribute.ul.toJson(); + final doc = Delta()..insert('Line1')..insert('\n\n\n\n', ul); + final actual = rule.apply(doc, 7, '\n'); + expect(actual, isNull); + }); }); group('$PreserveInlineStylesRule', () { @@ -211,8 +219,8 @@ void main() { }); }); - group('$PreserveBlockStyleOnPasteRule', () { - final rule = PreserveBlockStyleOnPasteRule(); + group('$PreserveBlockStyleOnInsertRule', () { + final rule = PreserveBlockStyleOnInsertRule(); test('applies in a block', () { final doc = Delta() @@ -228,6 +236,56 @@ void main() { expect(actual, isNotNull); expect(actual, expected); }); + + test('applies for single newline insert', () { + final doc = Delta() + ..insert('One and two') + ..insert('\n\n', ul) + ..insert('Three') + ..insert('\n', ul); + final actual = rule.apply(doc, 12, '\n'); + final expected = Delta() + ..retain(12) + ..insert('\n', ul); + expect(actual, expected); + }); + + test('applies for multi line insert', () { + final doc = Delta() + ..insert('One and two') + ..insert('\n\n', ul) + ..insert('Three') + ..insert('\n', ul); + final actual = rule.apply(doc, 8, '111\n222\n333'); + final expected = Delta() + ..retain(8) + ..insert('111') + ..insert('\n', ul) + ..insert('222') + ..insert('\n', ul) + ..insert('333'); + expect(actual, expected); + }); + + test('preserves heading style of the original line', () { + final quote = NotusAttribute.block.quote.toJson(); + final h1_unset = NotusAttribute.heading.unset.toJson(); + final quote_h1 = NotusAttribute.block.quote.toJson(); + quote_h1.addAll(NotusAttribute.heading.level1.toJson()); + final doc = Delta() + ..insert('One and two') + ..insert('\n', quote_h1) + ..insert('Three') + ..insert('\n', quote); + final actual = rule.apply(doc, 8, '111\n'); + final expected = Delta() + ..retain(8) + ..insert('111') + ..insert('\n', quote_h1) + ..retain(3) + ..retain(1, h1_unset); + expect(actual, expected); + }); }); group('$InsertEmbedsRule', () { diff --git a/packages/zefyr/lib/src/widgets/editor.dart b/packages/zefyr/lib/src/widgets/editor.dart index 123d635a4..b18a91dd1 100644 --- a/packages/zefyr/lib/src/widgets/editor.dart +++ b/packages/zefyr/lib/src/widgets/editor.dart @@ -256,15 +256,10 @@ class _ZefyrEditorState extends State textSelectionControls = cupertinoTextSelectionControls; paintCursorAboveText = true; cursorOpacityAnimates = true; - if (theme.useTextSelectionTheme) { - cursorColor ??= - selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; - selectionColor = selectionTheme.selectionColor ?? - cupertinoTheme.primaryColor.withOpacity(0.40); - } else { - cursorColor ??= CupertinoTheme.of(context).primaryColor; - selectionColor = theme.textSelectionColor; - } + cursorColor ??= + selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; + selectionColor = selectionTheme.selectionColor ?? + cupertinoTheme.primaryColor.withOpacity(0.40); cursorRadius ??= const Radius.circular(2.0); cursorOffset = Offset( iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); @@ -277,15 +272,9 @@ class _ZefyrEditorState extends State textSelectionControls = materialTextSelectionControls; paintCursorAboveText = false; cursorOpacityAnimates = false; - if (theme.useTextSelectionTheme) { - cursorColor ??= - selectionTheme.cursorColor ?? theme.colorScheme.primary; - selectionColor = selectionTheme.selectionColor ?? - theme.colorScheme.primary.withOpacity(0.40); - } else { - cursorColor ??= theme.cursorColor; - selectionColor = theme.textSelectionColor; - } + cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; + selectionColor = selectionTheme.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); break; }