From 1a2c1cdaf83bfece98461fca1b29631a42f6efbc Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Tue, 18 Mar 2025 16:57:19 -0400 Subject: [PATCH 01/14] Fix: improve getDiff perfomance for the editor --- lib/src/delta/delta_diff.dart | 39 +++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index acbe17d9a..9a6084b0b 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -33,23 +33,36 @@ class Diff { /* Get diff operation between old text and new text */ Diff getDiff(String oldText, String newText, int cursorPosition) { - var end = oldText.length; - final delta = newText.length - end; - for (final limit = math.max(0, cursorPosition - delta); - end > limit && oldText[end - 1] == newText[end + delta - 1]; - end--) {} + final oldLength = oldText.length; + final newLength = newText.length; + final delta = newLength - oldLength; + var start = 0; - //TODO: we need to improve this part because this loop has a lot of unsafe index operations - for (final startLimit = cursorPosition - math.max(0, delta); - start < startLimit && - (start > oldText.length - 1 ? '' : oldText[start]) == - (start > newText.length - 1 ? '' : newText[start]); - start++) {} + var end = oldLength; + + // limit the range where we want to check for changes + final startLimit = cursorPosition - math.max(0, delta); + final endLimit = math.max(0, cursorPosition - delta); + + // find where the start the difference between old and new text + while (start < startLimit && + start < oldLength && + start < newLength && + oldText[start] == newText[start]) { + start++; + } + + // find where ends the difference between old and new text + while (end > endLimit && + end > start && + oldText[end - 1] == newText[end + delta - 1]) { + end--; + } + final deleted = (start >= end) ? '' : oldText.substring(start, end); - // we need to make the check if the start is major than the end because if we directly get the - // new inserted text without checking first, this will always throw an error since this is an unsafe op final inserted = (start >= end + delta) ? '' : newText.substring(start, end + delta); + return Diff( start: start, deleted: deleted, From 674db97ea7e477f7e46fd9eeac35a80b46d6cbe6 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Tue, 18 Mar 2025 18:24:41 -0400 Subject: [PATCH 02/14] Chore: avoid make a diff when both strings are equals --- lib/src/delta/delta_diff.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index 9a6084b0b..0d662410b 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -33,6 +33,8 @@ class Diff { /* Get diff operation between old text and new text */ Diff getDiff(String oldText, String newText, int cursorPosition) { + if (oldText == newText) + return Diff(deleted: '', inserted: '', start: cursorPosition); final oldLength = oldText.length; final newLength = newText.length; final delta = newLength - oldLength; From 5ef6145b2eb81c8cbc026014230a8fd326dcda4a Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Tue, 18 Mar 2025 18:25:13 -0400 Subject: [PATCH 03/14] Fix: bad format in if condition --- lib/src/delta/delta_diff.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index 0d662410b..f2779535a 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -33,8 +33,9 @@ class Diff { /* Get diff operation between old text and new text */ Diff getDiff(String oldText, String newText, int cursorPosition) { - if (oldText == newText) + if (oldText == newText) { return Diff(deleted: '', inserted: '', start: cursorPosition); + } final oldLength = oldText.length; final newLength = newText.length; final delta = newLength - oldLength; From 8d32414bba532081684f9a59cae8d1e226b10ce5 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Tue, 18 Mar 2025 18:56:04 -0400 Subject: [PATCH 04/14] Fix: the diff is gettings a positions where theres no changes --- lib/src/delta/delta_diff.dart | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index f2779535a..c76b05229 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -36,35 +36,32 @@ Diff getDiff(String oldText, String newText, int cursorPosition) { if (oldText == newText) { return Diff(deleted: '', inserted: '', start: cursorPosition); } + final oldLength = oldText.length; final newLength = newText.length; - final delta = newLength - oldLength; var start = 0; - var end = oldLength; - - // limit the range where we want to check for changes - final startLimit = cursorPosition - math.max(0, delta); - final endLimit = math.max(0, cursorPosition - delta); + var endOld = oldLength; + var endNew = newLength; - // find where the start the difference between old and new text - while (start < startLimit && - start < oldLength && + // find where starts the difference between old and new text + while (start < oldLength && start < newLength && oldText[start] == newText[start]) { start++; } // find where ends the difference between old and new text - while (end > endLimit && - end > start && - oldText[end - 1] == newText[end + delta - 1]) { - end--; + while (endOld > start && + endNew > start && + oldText[endOld - 1] == newText[endNew - 1]) { + endOld--; + endNew--; } - final deleted = (start >= end) ? '' : oldText.substring(start, end); - final inserted = - (start >= end + delta) ? '' : newText.substring(start, end + delta); + // get the changes + final deleted = oldText.substring(start, endOld); + final inserted = newText.substring(start, endNew); return Diff( start: start, From acb57a0d381f01f64afdc7d47c3bafde4b1046f4 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 01:53:29 -0400 Subject: [PATCH 05/14] Chore: change focus to use selection for limit the range of diff --- lib/src/delta/delta_diff.dart | 194 +++++++++++++++--- ...editor_state_selection_delegate_mixin.dart | 16 +- 2 files changed, 177 insertions(+), 33 deletions(-) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index c76b05229..d3e93cfb4 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -1,7 +1,6 @@ import 'dart:math' as math; -import 'dart:ui' show TextDirection; -import 'package:flutter/foundation.dart' show immutable; +import 'package:flutter/material.dart'; import '../../quill_delta.dart'; import '../document/attribute.dart'; @@ -16,6 +15,27 @@ class Diff { required this.inserted, }); + const Diff.insert({ + required this.start, + required this.inserted, + }) : deleted = ''; + + const Diff.noDiff({ + this.start = 0, + }) : deleted = '', + inserted = ''; + + const Diff.delete({ + required this.start, + required this.deleted, + }) : inserted = ''; + + bool get isDelete => inserted.isEmpty && deleted.isNotEmpty; + + bool get isInsert => inserted.isNotEmpty && deleted.isEmpty; + + bool get hasNoDiff => inserted.isEmpty && deleted.isEmpty; + // Start index in old text at which changes begin. final int start; @@ -31,43 +51,159 @@ class Diff { } } -/* Get diff operation between old text and new text */ -Diff getDiff(String oldText, String newText, int cursorPosition) { - if (oldText == newText) { - return Diff(deleted: '', inserted: '', start: cursorPosition); +/// Get text changes between two strings using [oldStr] and [newStr] +/// using selection as the base with [oldSelection] and [newSelection]. +/// +/// How it works: +/// 1. Focuses comparison around the cursor/selection area (optimized for 99% of edits) +/// 2. Uses bidirectional scanning to pinpoint exact change boundaries +/// 3. Validates changes against cursor movement patterns +/// 4. Falls back to full comparison only for complex cases +/// +/// Typical use case: +/// - User types "A" at position 5 in "Flutter" → "FluttAer" +/// - Returns Diff.insert(start: 5, inserted: "A") +/// +/// Performance: O([k]) where [k] == change size (not document length) +Diff getDiff( + String oldStr, + String newStr, + TextSelection oldSelection, + TextSelection newSelection, +) { + if (oldStr == newStr) return Diff.noDiff(start: newSelection.start); + + // 1. Calculate affected range based on selections + final affectedRange = + _getAffectedRange(oldStr, newStr, oldSelection, newSelection); + var start = affectedRange.start; + final end = affectedRange.end; + + // 2. Adjust bounds for length variations + final oldLen = oldStr.length; + final newLen = newStr.length; + final lengthDiff = newLen - oldLen; + + // 3. Forward search from range start + while (start < end && + start < oldLen && + start < newLen && + oldStr[start] == newStr[start]) { + start++; + } + + // 4. Backward search from range end + var oldEnd = math.min(end, oldLen); + var newEnd = math.min(end + lengthDiff, newLen); + + while (oldEnd > start && + newEnd > start && + oldStr[oldEnd - 1] == newStr[newEnd - 1]) { + oldEnd--; + newEnd--; + } + + // 5. Extract differences + final deleted = oldStr.substring(start, oldEnd); + final inserted = newStr.substring(start, newEnd); + + // 6. Validate consistency + if (_isChangeConsistent(deleted, inserted, oldSelection, newSelection)) { + return _buildDiff(deleted, inserted, start); + } + + // Fallback for complex cases + return _fallbackDiff(oldStr, newStr, start, end); +} + +TextRange _getAffectedRange( + String oldStr, + String newStr, + TextSelection oldSel, + TextSelection newSel, +) { + // Calculate combined selection area + final start = math.min(oldSel.start, newSel.start); + final end = math.max(oldSel.end, newSel.end); + + // Expand by 20% to capture nearby changes + // + // We use this to avoid check all the string length + // unnecessarily when we can use the selection as a base + // to know where, and how was do it the change + final expansion = ((end - start) * 0.2).round(); + + return TextRange( + start: math.max(0, start - expansion), + end: math.min(math.max(oldStr.length, newStr.length), end + expansion), + ); +} + +bool _isChangeConsistent( + String deleted, + String inserted, + TextSelection oldSel, + TextSelection newSel, +) { + final isInsert = newSel.start == newSel.end && inserted.isNotEmpty; + final isDelete = deleted.isNotEmpty && inserted.isEmpty; + + // Insert validation + if (isInsert) { + return newSel.start == oldSel.start + inserted.length; } - final oldLength = oldText.length; - final newLength = newText.length; + // Delete validation + if (isDelete) { + return oldSel.start - newSel.start == deleted.length; + } + + return true; +} - var start = 0; - var endOld = oldLength; - var endNew = newLength; +Diff _fallbackDiff(String oldStr, String newStr, int start, [int? end]) { + end ??= math.min(oldStr.length, newStr.length); - // find where starts the difference between old and new text - while (start < oldLength && - start < newLength && - oldText[start] == newText[start]) { + // 1. Find first divergence point + while (start < end && + start < oldStr.length && + start < newStr.length && + oldStr[start] == newStr[start]) { start++; } - // find where ends the difference between old and new text - while (endOld > start && - endNew > start && - oldText[endOld - 1] == newText[endNew - 1]) { - endOld--; - endNew--; + // 2. Find last divergence point + var oldEnd = oldStr.length; + var newEnd = newStr.length; + + while (oldEnd > start && + newEnd > start && + oldStr[oldEnd - 1] == newStr[newEnd - 1]) { + oldEnd--; + newEnd--; } - // get the changes - final deleted = oldText.substring(start, endOld); - final inserted = newText.substring(start, endNew); + // 3. Extract differences + final deleted = oldStr.substring(start, oldEnd); + final inserted = newStr.substring(start, newEnd); - return Diff( - start: start, - deleted: deleted, - inserted: inserted, - ); + return _buildDiff(deleted, inserted, start); +} + +Diff _buildDiff(String deleted, String inserted, int start) { + if (deleted.isEmpty && inserted.isEmpty) return const Diff.noDiff(); + + if (deleted.isNotEmpty && inserted.isNotEmpty) { + return Diff( + inserted: inserted, + start: start, + deleted: deleted, + ); + } else if (inserted.isNotEmpty) { + return Diff.insert(start: start, inserted: inserted); + } else { + return Diff.delete(start: start, deleted: deleted); + } } int getPositionDelta(Delta user, Delta actual) { diff --git a/lib/src/editor/raw_editor/raw_editor_state_selection_delegate_mixin.dart b/lib/src/editor/raw_editor/raw_editor_state_selection_delegate_mixin.dart index 9746711a5..6edaf7edb 100644 --- a/lib/src/editor/raw_editor/raw_editor_state_selection_delegate_mixin.dart +++ b/lib/src/editor/raw_editor/raw_editor_state_selection_delegate_mixin.dart @@ -15,18 +15,26 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState } set textEditingValue(TextEditingValue value) { - final cursorPosition = value.selection.extentOffset; final oldText = widget.controller.document.toPlainText(); final newText = value.text; - final diff = getDiff(oldText, newText, cursorPosition); - if (diff.deleted == '' && diff.inserted == '') { + final diff = getDiff( + oldText, + newText, + widget.controller.selection, + value.selection, + ); + if (diff.hasNoDiff) { // Only changing selection range widget.controller.updateSelection(value.selection, ChangeSource.local); return; } widget.controller.replaceTextWithEmbeds( - diff.start, diff.deleted.length, diff.inserted, value.selection); + diff.start, + diff.deleted.length, + diff.inserted, + value.selection, + ); } @override From cb75a7393aefdde9046c46c0c8b14064ff84ab32 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 01:55:27 -0400 Subject: [PATCH 06/14] Chore: removed unnecessary comments --- lib/src/delta/delta_diff.dart | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index d3e93cfb4..deefd725c 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -54,16 +54,6 @@ class Diff { /// Get text changes between two strings using [oldStr] and [newStr] /// using selection as the base with [oldSelection] and [newSelection]. /// -/// How it works: -/// 1. Focuses comparison around the cursor/selection area (optimized for 99% of edits) -/// 2. Uses bidirectional scanning to pinpoint exact change boundaries -/// 3. Validates changes against cursor movement patterns -/// 4. Falls back to full comparison only for complex cases -/// -/// Typical use case: -/// - User types "A" at position 5 in "Flutter" → "FluttAer" -/// - Returns Diff.insert(start: 5, inserted: "A") -/// /// Performance: O([k]) where [k] == change size (not document length) Diff getDiff( String oldStr, From a5865cbe0fd1852fb1183b387e45e63b9bdbef6e Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 01:57:42 -0400 Subject: [PATCH 07/14] Fix: input clinet --- lib/src/delta/delta_diff.dart | 3 +++ .../raw_editor_state_text_input_client_mixin.dart | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index deefd725c..61049c1ec 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -30,10 +30,13 @@ class Diff { required this.deleted, }) : inserted = ''; + /// Checks if the diff is just a delete bool get isDelete => inserted.isEmpty && deleted.isNotEmpty; + /// Checks if the diff is just an isnertion bool get isInsert => inserted.isNotEmpty && deleted.isEmpty; + /// Checks if the diff has no changes bool get hasNoDiff => inserted.isEmpty && deleted.isEmpty; // Start index in old text at which changes begin. diff --git a/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart index dc9b0ba11..5fd8da5b3 100644 --- a/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart +++ b/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart @@ -222,8 +222,12 @@ mixin RawEditorStateTextInputClientMixin on EditorState _lastKnownRemoteTextEditingValue = value; final oldText = effectiveLastKnownValue.text; final text = value.text; - final cursorPosition = value.selection.extentOffset; - final diff = getDiff(oldText, text, cursorPosition); + final diff = getDiff( + oldText, + text, + widget.controller.selection, + value.selection, + ); if (diff.deleted.isEmpty && diff.inserted.isEmpty) { widget.controller.updateSelection(value.selection, ChangeSource.local); } else { From df81e1d0476c3bc2e24572dc983e2e7db8ac1b6c Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 01:58:50 -0400 Subject: [PATCH 08/14] Chore: replaced manual validating to use hasNoDiff method from Diff for input client --- .../raw_editor/raw_editor_state_text_input_client_mixin.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart index 5fd8da5b3..fe326728b 100644 --- a/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart +++ b/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart @@ -228,7 +228,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState widget.controller.selection, value.selection, ); - if (diff.deleted.isEmpty && diff.inserted.isEmpty) { + if (diff.hasNoDiff) { widget.controller.updateSelection(value.selection, ChangeSource.local); } else { widget.controller.replaceText( From 68df502bc9de60be388af8f826f2119c0810a85f Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 02:01:42 -0400 Subject: [PATCH 09/14] Chore: added change to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57fc32bc9..e7e66e1d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Improve getDiff perfomance for the editor [#2517](https://github.com/singerdmx/flutter-quill/pull/2517) + ## [11.1.2] - 2025-03-24 ### Fixed From e8d6fb5e2bb9cbdb497b8a971d41f5bb88bf0c2f Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 02:07:48 -0400 Subject: [PATCH 10/14] Chore: added tests for getting diff --- lib/src/delta/delta_diff.dart | 3 + test/editor/get_diff_test.dart | 106 +++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 test/editor/get_diff_test.dart diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index 61049c1ec..0c3f0b930 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -33,6 +33,9 @@ class Diff { /// Checks if the diff is just a delete bool get isDelete => inserted.isEmpty && deleted.isNotEmpty; + /// Checks if the diff is just replace + bool get isReplace => inserted.isNotEmpty && deleted.isNotEmpty; + /// Checks if the diff is just an isnertion bool get isInsert => inserted.isNotEmpty && deleted.isEmpty; diff --git a/test/editor/get_diff_test.dart b/test/editor/get_diff_test.dart new file mode 100644 index 000000000..351571f6c --- /dev/null +++ b/test/editor/get_diff_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/services.dart' show TextSelection; +import 'package:flutter_quill/src/delta/delta_diff.dart'; +import 'package:test/test.dart'; + +void main() { + group('Performance Tests', () { + late Stopwatch stopwatch; + const loremIpsum = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '; + + setUp(() => stopwatch = Stopwatch()); + + test('Simple insert (should complete <10ms)', () { + // Small text (50 chars) + final text = loremIpsum.substring(0, 50); + const selection = TextSelection.collapsed(offset: 10); + + stopwatch.start(); + final diff = getDiff( + text, + text.replaceRange(10, 10, 'X'), // Insert 'X' at position 10 + selection, + const TextSelection.collapsed(offset: 11), + ); + stopwatch.stop(); + + expect(diff.isInsert, isTrue); + expect(stopwatch.elapsedMilliseconds, lessThan(10)); + }); + + test('Medium replace (should complete <50ms)', () { + // Medium text (1,000 chars) + final text = List.generate(20, (_) => loremIpsum).join(); + const selection = TextSelection(baseOffset: 100, extentOffset: 105); + + stopwatch.start(); + final diff = getDiff( + text, + text.replaceRange(100, 105, 'NEW'), // Replace 5 chars with "NEW" + selection, + const TextSelection.collapsed(offset: 103), + ); + stopwatch.stop(); + + expect(diff.isReplace, isTrue); + expect(stopwatch.elapsedMilliseconds, lessThan(50)); + }); + + test('Large document edit (should complete <500ms)', () { + // Large text (100,000 chars) + final text = List.generate(2000, (_) => loremIpsum).join(); + const selection = TextSelection.collapsed(offset: 50000); + + stopwatch.start(); + final diff = getDiff( + text, + text.replaceRange(50000, 50000, 'INSERTION'), // Insert at position 50k + selection, + const TextSelection.collapsed(offset: 50008), + ); + stopwatch.stop(); + + expect(diff.isInsert, isTrue); + expect(stopwatch.elapsedMilliseconds, lessThan(500)); + }); + + test('Complex multi-edit fallback (should complete <1000ms)', () { + final text = List.generate(1000, (_) => loremIpsum).join(); + const selection = TextSelection(baseOffset: 1000, extentOffset: 1005); + + // Simulate paste with multiple changes + stopwatch.start(); + getDiff( + text, + text + .replaceRange(1000, 1005, 'ABC') + .replaceRange(2000, 2001, 'X') // Second unrelated change + .replaceRange(3000, 3002, 'YZ'), // Third change + selection, + const TextSelection.collapsed(offset: 1003), + ); + stopwatch.stop(); + + expect(stopwatch.elapsedMilliseconds, lessThan(1000)); + }); + + test('Worst-case full diff (should complete <2000ms)', () { + // Two completely different large texts + final text1 = List.generate(5000, (i) => 'Line $i: $loremIpsum\n').join(); + final text2 = + List.generate(5000, (i) => 'Modified ${i * 2}: $loremIpsum\n').join(); + + stopwatch.start(); + getDiff( + text1, + text2, + const TextSelection.collapsed(offset: 0), + const TextSelection.collapsed(offset: 0), + ); + stopwatch.stop(); + + expect(stopwatch.elapsedMilliseconds, lessThan(2000)); + }); + }); +} From db74bd63c1258b7982e3b6d792bfc1510aa29e62 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 02:39:05 -0400 Subject: [PATCH 11/14] Fix: forward key issues and keypressed issues --- lib/src/delta/delta_diff.dart | 58 +++++++++++++++++++++++++++++----- test/editor/get_diff_test.dart | 16 ++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index 0c3f0b930..34915f602 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -33,13 +33,13 @@ class Diff { /// Checks if the diff is just a delete bool get isDelete => inserted.isEmpty && deleted.isNotEmpty; - /// Checks if the diff is just replace + /// Checks if the diff is just replace bool get isReplace => inserted.isNotEmpty && deleted.isNotEmpty; - /// Checks if the diff is just an isnertion + /// Checks if the diff is just an isnertion bool get isInsert => inserted.isNotEmpty && deleted.isEmpty; - /// Checks if the diff has no changes + /// Checks if the diff has no changes bool get hasNoDiff => inserted.isEmpty && deleted.isEmpty; // Start index in old text at which changes begin. @@ -72,8 +72,12 @@ Diff getDiff( // 1. Calculate affected range based on selections final affectedRange = _getAffectedRange(oldStr, newStr, oldSelection, newSelection); - var start = affectedRange.start; - final end = affectedRange.end; + var start = affectedRange.start + .clamp(0, math.min(oldStr.length, newStr.length)) + .toInt(); + final end = affectedRange.end + .clamp(0, math.max(oldStr.length, newStr.length)) + .toInt(); // 2. Adjust bounds for length variations final oldLen = oldStr.length; @@ -99,12 +103,16 @@ Diff getDiff( newEnd--; } + final safeOldEnd = oldEnd.clamp(start, oldStr.length); + final safeNewEnd = newEnd.clamp(start, newStr.length); + // 5. Extract differences - final deleted = oldStr.substring(start, oldEnd); - final inserted = newStr.substring(start, newEnd); + final deleted = oldStr.substring(start, safeOldEnd); + final inserted = newStr.substring(start, safeNewEnd); // 6. Validate consistency - if (_isChangeConsistent(deleted, inserted, oldSelection, newSelection)) { + if (_isChangeConsistent( + deleted, inserted, oldStr, oldSelection, newSelection)) { return _buildDiff(deleted, inserted, start); } @@ -138,9 +146,20 @@ TextRange _getAffectedRange( bool _isChangeConsistent( String deleted, String inserted, + String oldText, TextSelection oldSel, TextSelection newSel, ) { + final isForwardDelete = _isForwardDelete( + deletedText: deleted, + oldText: oldText, + oldSelection: oldSel, + newSelection: newSel, + ); + if (isForwardDelete) { + return newSel.start == oldSel.start && + deleted.length == (oldSel.end - oldSel.start); + } final isInsert = newSel.start == newSel.end && inserted.isNotEmpty; final isDelete = deleted.isNotEmpty && inserted.isEmpty; @@ -157,6 +176,29 @@ bool _isChangeConsistent( return true; } +/// Detect if the deletion was do it to forward +bool _isForwardDelete({ + required String deletedText, + required String oldText, + required TextSelection oldSelection, + required TextSelection newSelection, +}) { + // is forward delete if: + return + // 1. There's deleted text + deletedText.isNotEmpty && + + // 2. The original selection is collaped + oldSelection.isCollapsed && + + // 3. New and original selections has the same offset + newSelection.isCollapsed && + newSelection.baseOffset == oldSelection.baseOffset && + + // 4. The removed character if after the cursor position + (oldSelection.baseOffset + deletedText.length <= oldText.length); +} + Diff _fallbackDiff(String oldStr, String newStr, int start, [int? end]) { end ??= math.min(oldStr.length, newStr.length); diff --git a/test/editor/get_diff_test.dart b/test/editor/get_diff_test.dart index 351571f6c..999b61aed 100644 --- a/test/editor/get_diff_test.dart +++ b/test/editor/get_diff_test.dart @@ -102,5 +102,21 @@ void main() { expect(stopwatch.elapsedMilliseconds, lessThan(2000)); }); + + test('Simulates forward deletion (should complete <10ms)', () { + // A simple but large text + final text1 = List.generate(5000, (i) => 'Line $i: $loremIpsum\n').join(); + + stopwatch.start(); + getDiff( + text1, + text1.replaceRange(4500, 4501, ''), + const TextSelection.collapsed(offset: 4500), + const TextSelection.collapsed(offset: 4500), + ); + stopwatch.stop(); + + expect(stopwatch.elapsedMilliseconds, lessThan(10)); + }); }); } From 81e2ffc4e5ff8467cd55c0567b732404fb182b25 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 03:04:44 -0400 Subject: [PATCH 12/14] Fix: issues with backspaces not working at certain cases --- lib/src/delta/delta_diff.dart | 47 +++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index 34915f602..084a8f9a3 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -85,10 +85,17 @@ Diff getDiff( final lengthDiff = newLen - oldLen; // 3. Forward search from range start - while (start < end && - start < oldLen && - start < newLen && - oldStr[start] == newStr[start]) { + var hasForwardChange = false; + + while (start < end && start < oldStr.length && start < newStr.length) { + if (oldStr[start] != newStr[start]) { + hasForwardChange = true; + break; + } + // Force forward if the change comes only from the cursor position + if (start >= oldSelection.baseOffset && start >= newSelection.baseOffset) { + break; + } start++; } @@ -96,13 +103,37 @@ Diff getDiff( var oldEnd = math.min(end, oldLen); var newEnd = math.min(end + lengthDiff, newLen); - while (oldEnd > start && - newEnd > start && - oldStr[oldEnd - 1] == newStr[newEnd - 1]) { + var hasBackwardChange = false; + + while (oldEnd > start && newEnd > start) { + if (oldStr[oldEnd - 1] != newStr[newEnd - 1]) { + hasBackwardChange = true; + break; + } + // Breaks if the cursor still into the same position + if (oldEnd - 1 <= oldSelection.baseOffset && + newEnd - 1 <= newSelection.baseOffset) { + break; + } oldEnd--; newEnd--; } + // This is a workaround that fixes an issue where, when the cursor + // is between two characters, that are the same ("s|s"), when you + // press backspace key, instead removes the "s" character before the cursor, + // it just moves to left without removing nothing + if (!hasForwardChange && !hasBackwardChange) { + if (oldStr.length > newStr.length) { + return Diff.delete( + start: oldSelection.baseOffset < newSelection.baseOffset + ? oldSelection.baseOffset + : newSelection.baseOffset, + deleted: ' ', + ); + } + } + final safeOldEnd = oldEnd.clamp(start, oldStr.length); final safeNewEnd = newEnd.clamp(start, newStr.length); @@ -196,7 +227,7 @@ bool _isForwardDelete({ newSelection.baseOffset == oldSelection.baseOffset && // 4. The removed character if after the cursor position - (oldSelection.baseOffset + deletedText.length <= oldText.length); + oldText.startsWith(deletedText, oldSelection.baseOffset); } Diff _fallbackDiff(String oldStr, String newStr, int start, [int? end]) { From f10c7c3c12dd13012b7a32d3a6016daed6127922 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 03:07:50 -0400 Subject: [PATCH 13/14] Chore: renamed get diff test to be congruent with the target of the file --- test/editor/{get_diff_test.dart => perfomance_get_diff_test.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/editor/{get_diff_test.dart => perfomance_get_diff_test.dart} (100%) diff --git a/test/editor/get_diff_test.dart b/test/editor/perfomance_get_diff_test.dart similarity index 100% rename from test/editor/get_diff_test.dart rename to test/editor/perfomance_get_diff_test.dart From ce488d8ef180d78fe450ac280892a4d70bdcfc2f Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Wed, 26 Mar 2025 03:13:31 -0400 Subject: [PATCH 14/14] Chore: format test description to follow the conventions of dart --- test/editor/perfomance_get_diff_test.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/editor/perfomance_get_diff_test.dart b/test/editor/perfomance_get_diff_test.dart index 999b61aed..ceea30936 100644 --- a/test/editor/perfomance_get_diff_test.dart +++ b/test/editor/perfomance_get_diff_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_quill/src/delta/delta_diff.dart'; import 'package:test/test.dart'; void main() { - group('Performance Tests', () { + group('performance Tests', () { late Stopwatch stopwatch; const loremIpsum = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' @@ -11,7 +11,7 @@ void main() { setUp(() => stopwatch = Stopwatch()); - test('Simple insert (should complete <10ms)', () { + test('simple insert (should complete <10ms)', () { // Small text (50 chars) final text = loremIpsum.substring(0, 50); const selection = TextSelection.collapsed(offset: 10); @@ -29,7 +29,7 @@ void main() { expect(stopwatch.elapsedMilliseconds, lessThan(10)); }); - test('Medium replace (should complete <50ms)', () { + test('medium replace (should complete <50ms)', () { // Medium text (1,000 chars) final text = List.generate(20, (_) => loremIpsum).join(); const selection = TextSelection(baseOffset: 100, extentOffset: 105); @@ -47,7 +47,7 @@ void main() { expect(stopwatch.elapsedMilliseconds, lessThan(50)); }); - test('Large document edit (should complete <500ms)', () { + test('large document edit (should complete <500ms)', () { // Large text (100,000 chars) final text = List.generate(2000, (_) => loremIpsum).join(); const selection = TextSelection.collapsed(offset: 50000); @@ -65,7 +65,7 @@ void main() { expect(stopwatch.elapsedMilliseconds, lessThan(500)); }); - test('Complex multi-edit fallback (should complete <1000ms)', () { + test('complex multi-edit fallback (should complete <1000ms)', () { final text = List.generate(1000, (_) => loremIpsum).join(); const selection = TextSelection(baseOffset: 1000, extentOffset: 1005); @@ -85,7 +85,7 @@ void main() { expect(stopwatch.elapsedMilliseconds, lessThan(1000)); }); - test('Worst-case full diff (should complete <2000ms)', () { + test('worst-case full diff (should complete <2000ms)', () { // Two completely different large texts final text1 = List.generate(5000, (i) => 'Line $i: $loremIpsum\n').join(); final text2 = @@ -103,7 +103,7 @@ void main() { expect(stopwatch.elapsedMilliseconds, lessThan(2000)); }); - test('Simulates forward deletion (should complete <10ms)', () { + test('simulates forward deletion (should complete <10ms)', () { // A simple but large text final text1 = List.generate(5000, (i) => 'Line $i: $loremIpsum\n').join();