Skip to content

Chore: improve getDiff perfomance for the editor #2517

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.2.0] - 2025-03-26

### Added
Expand Down
268 changes: 243 additions & 25 deletions lib/src/delta/delta_diff.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +15,33 @@ 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 = '';

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

/// Checks if the diff has no changes
bool get hasNoDiff => inserted.isEmpty && deleted.isEmpty;

// Start index in old text at which changes begin.
final int start;

Expand All @@ -31,30 +57,222 @@ 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--) {}
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++) {}
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,
inserted: inserted,
/// Get text changes between two strings using [oldStr] and [newStr]
/// using selection as the base with [oldSelection] and [newSelection].
///
/// 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
.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;
final newLen = newStr.length;
final lengthDiff = newLen - oldLen;

// 3. Forward search from range 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++;
}

// 4. Backward search from range end
var oldEnd = math.min(end, oldLen);
var newEnd = math.min(end + lengthDiff, newLen);

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

// 5. Extract differences
final deleted = oldStr.substring(start, safeOldEnd);
final inserted = newStr.substring(start, safeNewEnd);

// 6. Validate consistency
if (_isChangeConsistent(
deleted, inserted, oldStr, 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,
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;

// Insert validation
if (isInsert) {
return newSel.start == oldSel.start + inserted.length;
}

// Delete validation
if (isDelete) {
return oldSel.start - newSel.start == deleted.length;
}

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
oldText.startsWith(deletedText, oldSelection.baseOffset);
}

Diff _fallbackDiff(String oldStr, String newStr, int start, [int? end]) {
end ??= math.min(oldStr.length, newStr.length);

// 1. Find first divergence point
while (start < end &&
start < oldStr.length &&
start < newStr.length &&
oldStr[start] == newStr[start]) {
start++;
}

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

// 3. Extract differences
final deleted = oldStr.substring(start, oldEnd);
final inserted = newStr.substring(start, newEnd);

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,13 @@ 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);
if (diff.deleted.isEmpty && diff.inserted.isEmpty) {
final diff = getDiff(
oldText,
text,
widget.controller.selection,
value.selection,
);
if (diff.hasNoDiff) {
widget.controller.updateSelection(value.selection, ChangeSource.local);
} else {
widget.controller.replaceText(
Expand Down
Loading