Skip to content
Open
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
1 change: 1 addition & 0 deletions assets/dictionary/words.txt

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions devtools_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
1 change: 1 addition & 0 deletions example/lib/pages/desktop_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class _DesktopEditorState extends State<DesktopEditor> {
commandShortcutEvents: commandShortcuts,
editorStyle: editorStyle,
enableAutoComplete: true,
enableSpellChecker: true,
autoCompleteTextProvider: _buildAutoCompleteTextProvider,
dropTargetStyle: const AppFlowyDropTargetStyle(
color: Colors.red,
Expand Down
1 change: 1 addition & 0 deletions lib/appflowy_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ export 'src/service/default_text_operations/format_rich_text_style.dart';
export 'src/service/internal_key_event_handlers/copy_paste_handler.dart';
export 'src/service/shortcut_event/key_mapping.dart';
export 'src/service/shortcut_event/keybinding.dart';
export 'src/service/spell_check/spell_check.dart';
219 changes: 206 additions & 13 deletions lib/src/editor/block_component/rich_text/appflowy_rich_text.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';

import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/rich_text/spell_check_overlay.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
Expand Down Expand Up @@ -125,6 +127,11 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
bool get enableAutoComplete =>
widget.editorState.enableAutoComplete && autoCompleteTextProvider != null;

bool get enableSpellChecker => widget.editorState.enableSpellChecker;

AppFlowySpellCheckConfiguration get spellCheckConfiguration =>
widget.editorState.spellCheckConfiguration;

TextStyleConfiguration get textStyleConfiguration =>
widget.editorState.editorStyle.textStyleConfiguration;

Expand All @@ -138,13 +145,24 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
confirmContextEnabled();
}

@override
void dispose() {
// Cancel all pending spell check timers to prevent memory leaks
for (final timer in _debounceTimers.values) {
timer?.cancel();
}
_debounceTimers.clear();
super.dispose();
}

@override
Widget build(BuildContext context) {
Widget child = Stack(
children: [
_buildPlaceholderText(context),
_buildRichText(context),
..._buildRichTextOverlay(context),
if (enableSpellChecker) _buildSpellCheckOverlay(),
],
);

Expand Down Expand Up @@ -451,6 +469,17 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
[];
}

Widget _buildSpellCheckOverlay() {
if (textKey.currentContext == null) return const SizedBox.shrink();

return SpellCheckOverlay(
editorState: widget.editorState,
node: widget.node,
delegate: this,
misspelledCache: _misspelledCache,
);
}

void confirmContextEnabled() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && textKey.currentContext == null) {
Expand Down Expand Up @@ -624,28 +653,192 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
);
}
}
final textSpan = TextSpan(
text: textInsert.text,
style: textStyle,
);
textSpans.add(
textSpanDecoratorForAttribute != null

if (enableSpellChecker) {
textSpans.addAll(
_buildTextSpansWithSpellCheck(context, textInsert, textStyle, offset),
);
} else {
final textSpan = TextSpan(
text: textInsert.text,
style: textStyle,
);
textSpans.add(
textSpanDecoratorForAttribute != null
? textSpanDecoratorForAttribute!(
context,
widget.node,
offset,
textInsert,
textSpan,
widget.textSpanDecorator?.call(textSpan) ?? textSpan,
)
: textSpan,
);
}

offset += textInsert.length;
}

return TextSpan(
children: textSpans,
);
}

final Map<String, bool> _misspelledCache = {};
final Map<String, Timer?> _debounceTimers = {};
static const int _maxCacheSize = 1000;

List<InlineSpan> _buildTextSpansWithSpellCheck(
BuildContext context,
TextInsert textInsert,
TextStyle textStyle,
int offset,
) {
final textSpans = <InlineSpan>[];
// Split the insert text into word and non-word tokens so we can
// underline misspelled words and attach hover suggestion UI.
final tokenReg = RegExp(r"(\w+|[^\w]+)");
final tokens =
tokenReg.allMatches(textInsert.text).map((m) => m.group(0)!).toList();
int innerIndex = 0;

final config = spellCheckConfiguration;

for (int i = 0; i < tokens.length; i++) {
final token = tokens[i];
final isWord = RegExp(r"^\w+$").hasMatch(token);
if (isWord) {
final word = token;
final lc = word.toLowerCase();

// Check if word should be spell-checked based on configuration
bool shouldCheck = word.length >= config.minWordLength;

// If checkOnlyCompletedWords is true, only check if next token is whitespace/punctuation
// Don't check the last token (still being typed)
if (shouldCheck && config.checkOnlyCompletedWords) {
final isLastToken = i == tokens.length - 1;
final nextToken = isLastToken ? null : tokens[i + 1];
// Only check if there's a next token AND it's whitespace/punctuation (not a word)
shouldCheck =
nextToken != null && !RegExp(r"^\w+$").hasMatch(nextToken);
}

// Check custom dictionary
if (shouldCheck && config.customDictionary.contains(word)) {
shouldCheck = false;
}

// Check exclude patterns
if (shouldCheck) {
for (final pattern in config.excludePatterns) {
if (pattern.hasMatch(word)) {
shouldCheck = false;
break;
}
}
}

// schedule async check for unknown words
// cache stored on state (to avoid repeated checks)
// IMPORTANT: Pass the original word (not lowercase) so capital letter check works!
if (shouldCheck && !_misspelledCache.containsKey(lc)) {
_checkWord(word);
}

// Only mark as misspelled if we've checked it and confirmed it's wrong
final isMisspelled = shouldCheck && _misspelledCache[lc] == true;

final spanStyle = isMisspelled
? textStyle.copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.wavy,
decorationColor: Colors.red,
)
: textStyle;

// Always use TextSpan - never WidgetSpan to avoid transaction errors
final textSpan = TextSpan(text: word, style: spanStyle);
final span = textSpanDecoratorForAttribute != null
? textSpanDecoratorForAttribute!(
context,
widget.node,
offset,
offset + innerIndex,
textInsert,
textSpan,
widget.textSpanDecorator?.call(textSpan) ?? textSpan,
)
: textSpan,
);
offset += textInsert.length;
: textSpan;

textSpans.add(span);
} else {
// non-word token (spaces, punctuation)
final textSpan = TextSpan(text: token, style: textStyle);
textSpans.add(textSpan);
}
innerIndex += token.length;
}

return TextSpan(
children: textSpans,
);
return textSpans;
}

void _checkWord(String word) {
// Pass the original word (with original casing) to the spell checker
// so it can properly check for capital letters, camelCase, etc.
// Store result in cache using lowercase key for lookup
final lc = word.toLowerCase();
final debounceDelay = spellCheckConfiguration.debounceDelay;

// Cancel existing timer for this word
_debounceTimers[lc]?.cancel();

// If debounce delay is zero, check immediately
if (debounceDelay == Duration.zero) {
_performSpellCheck(word, lc);
} else {
// Schedule check after debounce delay
_debounceTimers[lc] = Timer(debounceDelay, () {
_performSpellCheck(word, lc);
_debounceTimers.remove(lc);
});
}
}

void _performSpellCheck(String word, String lc) {
final config = spellCheckConfiguration;

// If custom dictionary is provided and not empty, use it directly
if (config.customDictionary.isNotEmpty) {
final exists = config.customDictionary.contains(lc);
final miss = !exists;

if (_misspelledCache[lc] != miss) {
_misspelledCache[lc] = miss;
if (mounted) setState(() {});
}

return;
}

// Otherwise use the bundled dictionary
SpellChecker.instance.contains(word).then((exists) {
final miss = !exists;
// avoid unnecessary setState
if (_misspelledCache[lc] != miss) {
_misspelledCache[lc] = miss;

// Limit cache size to prevent memory leak in long editing sessions
if (_misspelledCache.length > _maxCacheSize) {
_misspelledCache.clear();
}

if (mounted) setState(() {});
}
}).catchError((err) {
// treat as known on error
_misspelledCache[lc] = false;
});
Comment thread
PraveenDevamane marked this conversation as resolved.
}

TextSelection? textSelectionFromEditorSelection(Selection? selection) {
Expand Down
Loading