From 3fa8a46f1cdbd27c75b78bb2bf136dc77938b624 Mon Sep 17 00:00:00 2001 From: bin <17426470+boyan01@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:32:55 +0800 Subject: [PATCH] mixin_markdown_widget: select whole Chinese words on double click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter's bundled ICU has no CJK dictionary, so double clicking Chinese text selected a single character. Word selection now asks `word_segmenter`, which uses the platform's segmenter (CFStringTokenizer, system ICU, or Intl.Segmenter) and falls back to `TextPainter.getWordBoundary` when the platform has none. `我们使用Flutter开发桌面应用` now selects `开发` instead of `开`. Adds a dependency on `word_segmenter: ^0.1.0`. --- .../lib/src/render/selectable_block.dart | 34 ++++++++++++++----- packages/mixin_markdown_widget/pubspec.yaml | 1 + .../test/markdown_word_selection_test.dart | 31 ++++++++++------- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart b/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart index 532c9b3e..e7e0330b 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:word_segmenter/word_segmenter.dart'; import '../core/document.dart'; import '../selection/structured_block_selection.dart'; @@ -1118,8 +1119,8 @@ enum _SelectionCornerQuadrant { /// A word is a run of characters between whitespace and the separators of /// [_isWordCharacter], which include CJK punctuation. Text such as Chinese /// does not put spaces between words, so a run with non-ASCII characters is -/// narrowed to its ICU word boundary: the word of the character after -/// [offset], or before it when [upstream]. ASCII runs keep the separator rule. +/// narrowed to the word of the character after [offset], or before it when +/// [upstream]. ASCII runs keep the separator rule. ({int start, int end}) markdownWordRangeAt( String text, int offset, { @@ -1138,18 +1139,33 @@ enum _SelectionCornerQuadrant { } final run = text.substring(start, end); + final word = _wordInRun( + run, + (upstream ? offset - start - 1 : offset - start).clamp(0, run.length - 1), + ); + return (start: start + word.start, end: start + word.end); +} + +/// The word containing [index] in [run]. +/// +/// Flutter's bundled ICU has no CJK dictionary and splits Chinese into single +/// characters, so the platform's segmenter is asked first. +TextRange _wordInRun(String run, int index) { + final segments = wordBoundaries(run); + if (segments != null) { + for (final segment in segments) { + if (index < segment.end) { + return segment; + } + } + } final painter = TextPainter( text: TextSpan(text: run), textDirection: TextDirection.ltr, )..layout(); - final boundary = painter.getWordBoundary( - TextPosition( - offset: (upstream ? offset - start - 1 : offset - start) - .clamp(0, run.length - 1), - ), - ); + final boundary = painter.getWordBoundary(TextPosition(offset: index)); painter.dispose(); - return (start: start + boundary.start, end: start + boundary.end); + return boundary; } bool _isWordCharacter(String character) { diff --git a/packages/mixin_markdown_widget/pubspec.yaml b/packages/mixin_markdown_widget/pubspec.yaml index de757c79..8efd9fe9 100644 --- a/packages/mixin_markdown_widget/pubspec.yaml +++ b/packages/mixin_markdown_widget/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: pretext: ^0.1.0 re_highlight: ^0.0.3 flutter_math_fork: ^0.7.4 + word_segmenter: ^0.1.0 dev_dependencies: flutter_test: diff --git a/packages/mixin_markdown_widget/test/markdown_word_selection_test.dart b/packages/mixin_markdown_widget/test/markdown_word_selection_test.dart index ce5d1b7a..f060b289 100644 --- a/packages/mixin_markdown_widget/test/markdown_word_selection_test.dart +++ b/packages/mixin_markdown_widget/test/markdown_word_selection_test.dart @@ -95,15 +95,18 @@ void main() { expect('你好', contains(_wordAt(text, 2))); }); - testWidgets('Chinese text breaks at ICU word boundaries', (tester) async { + testWidgets('Chinese text breaks at dictionary words', (tester) async { const text = '我们使用Flutter开发桌面应用'; - final flutterStart = text.indexOf('Flutter'); - for (var offset = flutterStart; offset < flutterStart + 7; offset += 1) { - expect(_wordAt(text, offset), 'Flutter'); + for (final word in ['我们', '使用', 'Flutter', '开发', '桌面', '应用']) { + final start = text.indexOf(word); + for (var offset = start; offset < start + word.length; offset += 1) { + expect(_wordAt(text, offset), word, reason: '$offset'); + } + // The pointer on the right half of the word's last character. + final end = start + word.length; + final range = markdownWordRangeAt(text, end, upstream: true); + expect(text.substring(range.start, range.end), word); } - final develop = _wordAt(text, text.indexOf('开')); - expect(develop, startsWith('开')); - expect(develop.length, lessThan(text.length - flutterStart - 7)); }); testWidgets('double click selects a word in a Chinese paragraph', @@ -134,11 +137,15 @@ void main() { containing: '我们使用', character: '效', ); - final selected = selectionController.selectedPlainText; - expect(selected, startsWith('效')); - expect(selected, isNot(contains(','))); - expect(selected, isNot(contains('。'))); - expect(selected.length, lessThan('效果很好'.length + 1)); + expect(selectionController.selectedPlainText, '效果'); + + await _resetTapCount(tester); + await _doubleTapCharacter( + tester, + containing: '我们使用', + character: '桌', + ); + expect(selectionController.selectedPlainText, '桌面'); }); testWidgets('double click selects a word in Chinese list text',