fix(app): furigana stays on its word, and readings typed as learners type
Furigana: - Text typed after a word with a reading no longer takes the reading. - A reading over text formatted in parts (a bold kanji) is drawn once, over all of it, each part in its own style; in Markdown it is one <ruby>, the parts keeping their marks, the reading escaped. - Readings follow the editor's text size, not the system's, and a linked word with a reading still opens its link. - Anki's <ruby> keeps the formatting inside it. - The Markdown export escapes what Markdown would read as its syntax and keeps blank lines inside code blocks. Typing a reading: - Hepburn as learners type it: onna is おんな, konnichiwa こんにちは, m before b, p and m is ん (shimbun); keyboards' n' and nnn still work. - The caret stays where the person types, also in the middle. - Macrons count both ways (ō as おう or おお); a card listing several readings (いく、ゆく) takes any of them; only words written with kanji ask for their reading. - No autocorrect, suggestions or curly quotes in the field; Done with nothing typed keeps the keyboard; the lock screen lets go of the field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4aed71c8db
commit
d5e5945d93
11 changed files with 468 additions and 101 deletions
|
|
@ -29,9 +29,9 @@ String richContentToMarkdown(
|
|||
required String? Function(String assetId) linkTo,
|
||||
}) {
|
||||
final document = content.toDocument();
|
||||
_formulasInDollars(document.root);
|
||||
_rubiesInHtml(document.root);
|
||||
return documentToMarkdown(
|
||||
_textForMarkdown(document.root);
|
||||
return _withoutExtraBlankLines(
|
||||
documentToMarkdown(
|
||||
document,
|
||||
customParsers: [
|
||||
_ImageParser(linkTo, texts.unavailable),
|
||||
|
|
@ -44,46 +44,101 @@ String richContentToMarkdown(
|
|||
// A blank line between blocks: Markdown runs lines without one into
|
||||
// one paragraph.
|
||||
lineBreak: '\n',
|
||||
).replaceAll(RegExp(r'\n{3,}'), '\n\n').trim();
|
||||
),
|
||||
).trim();
|
||||
}
|
||||
|
||||
/// Writes every formula within a line under [node] as its LaTeX between
|
||||
/// dollars, as Markdown's maths does.
|
||||
void _formulasInDollars(Node node) {
|
||||
if (node.delta case final delta?
|
||||
when delta.any((op) => op.attributes?[inlineMathKey] == true)) {
|
||||
/// [markdown] with no more than one blank line between blocks, as the
|
||||
/// encoder leaves some; inside a fenced code block every line stays.
|
||||
String _withoutExtraBlankLines(String markdown) {
|
||||
final parts = markdown.split('```');
|
||||
return [
|
||||
for (final (i, part) in parts.indexed)
|
||||
if (i.isEven) part.replaceAll(RegExp(r'\n{3,}'), '\n\n') else part,
|
||||
].join('```');
|
||||
}
|
||||
|
||||
/// Prepares the text of every line under [node] for Markdown: what
|
||||
/// Markdown would take as its own syntax escaped (`*`, `_`, `<`, a number
|
||||
/// and a full stop starting a line, …), formulas as their LaTeX between
|
||||
/// dollars, and furigana as HTML's ruby, which most Markdown viewers show
|
||||
/// (`<ruby>漢字<rt>かんじ</rt></ruby>`): once around a run of text with one
|
||||
/// reading, its pieces keeping their bold, links and the like.
|
||||
void _textForMarkdown(Node node) {
|
||||
if (node.delta case final delta?) {
|
||||
final ops = delta.toList();
|
||||
final written = Delta();
|
||||
for (final op in delta) {
|
||||
if (op is TextInsert && op.attributes?[inlineMathKey] == true) {
|
||||
for (var i = 0; i < ops.length; i++) {
|
||||
final op = ops[i];
|
||||
if (op is! TextInsert) {
|
||||
written.add(op);
|
||||
continue;
|
||||
}
|
||||
final attributes = op.attributes;
|
||||
if (attributes?[inlineMathKey] == true) {
|
||||
written.insert('\$${op.text}\$');
|
||||
} else {
|
||||
written.add(op);
|
||||
continue;
|
||||
}
|
||||
final reading = attributes?[rubyKey];
|
||||
if (reading is! String) {
|
||||
written.insert(
|
||||
_escaped(op.text, attributes, i == 0),
|
||||
attributes: attributes,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
written.insert('<ruby>');
|
||||
for (; i < ops.length; i++) {
|
||||
final piece = ops[i];
|
||||
if (piece is! TextInsert || piece.attributes?[rubyKey] != reading) {
|
||||
break;
|
||||
}
|
||||
final rest = {...?piece.attributes}..remove(rubyKey);
|
||||
written.insert(
|
||||
_escaped(piece.text, piece.attributes, false),
|
||||
attributes: rest.isEmpty ? null : rest,
|
||||
);
|
||||
}
|
||||
i--;
|
||||
written.insert('<rt>${_htmlEscaped(reading)}</rt></ruby>');
|
||||
}
|
||||
node.updateAttributes({'delta': written.toJson()});
|
||||
}
|
||||
node.children.forEach(_formulasInDollars);
|
||||
node.children.forEach(_textForMarkdown);
|
||||
}
|
||||
|
||||
/// Writes every text with furigana under [node] as HTML's ruby, which most
|
||||
/// Markdown viewers show: `<ruby>漢字<rt>かんじ</rt></ruby>`.
|
||||
void _rubiesInHtml(Node node) {
|
||||
if (node.delta case final delta?
|
||||
when delta.any((op) => op.attributes?[rubyKey] is String)) {
|
||||
final written = Delta();
|
||||
for (final op in delta) {
|
||||
if (op case TextInsert(:final text, :final attributes?)
|
||||
when attributes[rubyKey] is String) {
|
||||
written.insert('<ruby>$text<rt>${attributes[rubyKey]}</rt></ruby>');
|
||||
} else {
|
||||
written.add(op);
|
||||
/// [text] with what Markdown would read as its syntax escaped by a
|
||||
/// backslash; code as it is, as Markdown doesn't read inside it. [first]:
|
||||
/// the text starts its line, where a number with a full stop, `-`, `+`
|
||||
/// and `#` begin a list or heading.
|
||||
String _escaped(String text, Attributes? attributes, bool first) {
|
||||
if (attributes?[AppFlowyRichTextKeys.code] == true) {
|
||||
return text;
|
||||
}
|
||||
var escaped = text.replaceAllMapped(
|
||||
RegExp(r'[\\`*_\[\]<>|~$]'),
|
||||
(match) => '\\${match[0]}',
|
||||
);
|
||||
if (first) {
|
||||
escaped = escaped
|
||||
.replaceFirstMapped(
|
||||
RegExp(r'^(\s*\d+)([.)])'),
|
||||
(m) => '${m[1]}\\${m[2]}',
|
||||
)
|
||||
.replaceFirstMapped(
|
||||
RegExp(r'^(\s*)([-+#])'),
|
||||
(m) => '${m[1]}\\${m[2]}',
|
||||
);
|
||||
}
|
||||
node.updateAttributes({'delta': written.toJson()});
|
||||
}
|
||||
node.children.forEach(_rubiesInHtml);
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/// [text] for inside an HTML element.
|
||||
String _htmlEscaped(String text) => text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
class const _ImageParser(
|
||||
final String? Function(String assetId) _linkTo,
|
||||
final String _unavailable,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -38,10 +39,16 @@ Future<void> editRuby(BuildContext context, EditorState editorState) async {
|
|||
/// [editing], the text it reads dotted underneath, as the caret moves
|
||||
/// through it as text; else the reading set above the text, smaller, the
|
||||
/// text on the line's baseline.
|
||||
///
|
||||
/// A reading belongs to a run of text: pieces formatted apart within it (a
|
||||
/// bold kanji) share the one reading, drawn once over all of them, each
|
||||
/// piece in its own style. Typing at a run's end doesn't extend it.
|
||||
TextSpanDecoratorForAttribute withRuby(
|
||||
TextSpanDecoratorForAttribute base, {
|
||||
required bool editing,
|
||||
}) => (context, node, index, text, before, after) {
|
||||
}) {
|
||||
_endRubyWithItsText();
|
||||
return (context, node, index, text, before, after) {
|
||||
final span = base(context, node, index, text, before, after);
|
||||
final reading = text.attributes?[rubyKey];
|
||||
if (reading is! String || reading.isEmpty || span is! TextSpan) {
|
||||
|
|
@ -63,24 +70,113 @@ TextSpanDecoratorForAttribute withRuby(
|
|||
locale: span.locale,
|
||||
);
|
||||
}
|
||||
final run = _runAt(node.delta, index, reading);
|
||||
if (run == null) {
|
||||
// Drawn with the run's first piece.
|
||||
return const TextSpan();
|
||||
}
|
||||
final onTap = switch (span.recognizer) {
|
||||
final TapGestureRecognizer tap => tap.onTap,
|
||||
_ => null,
|
||||
};
|
||||
final ruby = RubyText(
|
||||
base: TextSpan(
|
||||
children: [
|
||||
for (final (i, piece) in run.indexed)
|
||||
TextSpan(
|
||||
text: piece.text,
|
||||
style: i == 0 ? style : _styled(style, piece.attributes),
|
||||
),
|
||||
],
|
||||
),
|
||||
reading: reading,
|
||||
style: style,
|
||||
);
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: RubyText(text: text.text, reading: reading, style: style),
|
||||
// A linked word with a reading still opens its link.
|
||||
child: onTap == null
|
||||
? ruby
|
||||
: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(onTap: onTap, child: ruby),
|
||||
),
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// [text] with its [reading] set above it, smaller and centred, the text in
|
||||
/// [style] on the baseline of the line around it.
|
||||
/// Lets text typed right after a reading's text go without its reading,
|
||||
/// as after a link: the editor otherwise gives typed text the attributes
|
||||
/// of the text before it.
|
||||
void _endRubyWithItsText() {
|
||||
if (!AppFlowyRichTextKeys.partialSliced.contains(rubyKey)) {
|
||||
AppFlowyRichTextKeys.partialSliced.add(rubyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pieces of text with [reading] that start at [index] in [delta], when
|
||||
/// the piece there is the first of them; null when one before it has the
|
||||
/// reading too.
|
||||
List<TextInsert>? _runAt(Delta? delta, int index, String reading) {
|
||||
if (delta == null) {
|
||||
return null;
|
||||
}
|
||||
final run = <TextInsert>[];
|
||||
var offset = 0;
|
||||
TextInsert? previous;
|
||||
for (final op in delta) {
|
||||
if (op is! TextInsert) {
|
||||
offset += op.length;
|
||||
previous = null;
|
||||
continue;
|
||||
}
|
||||
if (offset == index) {
|
||||
if (previous?.attributes?[rubyKey] == reading) {
|
||||
return null;
|
||||
}
|
||||
run.add(op);
|
||||
} else if (run.isNotEmpty) {
|
||||
if (op.attributes?[rubyKey] != reading) {
|
||||
break;
|
||||
}
|
||||
run.add(op);
|
||||
}
|
||||
offset += op.length;
|
||||
previous = op;
|
||||
}
|
||||
return run.isEmpty ? null : run;
|
||||
}
|
||||
|
||||
/// [style] with the formatting [attributes] give a piece of text: bold,
|
||||
/// italic, underlined or struck through.
|
||||
TextStyle _styled(TextStyle style, Attributes? attributes) => style.copyWith(
|
||||
fontWeight: attributes?[AppFlowyRichTextKeys.bold] == true
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
fontStyle: attributes?[AppFlowyRichTextKeys.italic] == true
|
||||
? FontStyle.italic
|
||||
: null,
|
||||
decoration: TextDecoration.combine([
|
||||
if (attributes?[AppFlowyRichTextKeys.underline] == true)
|
||||
TextDecoration.underline,
|
||||
if (attributes?[AppFlowyRichTextKeys.strikethrough] == true)
|
||||
TextDecoration.lineThrough,
|
||||
]),
|
||||
);
|
||||
|
||||
/// [base] with its [reading] set above it, smaller and centred, the text
|
||||
/// on the baseline of the line around it, [style] the line's. Neither
|
||||
/// follows the system's text scale, as the editor's own text doesn't.
|
||||
class const RubyText({
|
||||
required final String text,
|
||||
required final InlineSpan base,
|
||||
required final String reading,
|
||||
required final TextStyle style,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
label: '$text ($reading)',
|
||||
label: '${base.toPlainText()} ($reading)',
|
||||
excludeSemantics: true,
|
||||
// Laid out from the bottom: the text comes first, so the box takes its
|
||||
// baseline, and the line's text stays level with it.
|
||||
|
|
@ -88,12 +184,15 @@ class const RubyText({
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
verticalDirection: VerticalDirection.up,
|
||||
children: [
|
||||
Text(text, style: style),
|
||||
Text.rich(base, style: style, textScaler: TextScaler.noScaling),
|
||||
Text(
|
||||
reading,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: style.copyWith(
|
||||
fontSize: (style.fontSize ?? 16) * _rubyScale,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.normal,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const _romaji = {
|
|||
'sya': 'しゃ', 'syu': 'しゅ', 'syo': 'しょ',
|
||||
'cha': 'ちゃ', 'chu': 'ちゅ', 'cho': 'ちょ', 'chi': 'ち', 'che': 'ちぇ',
|
||||
'tya': 'ちゃ', 'tyu': 'ちゅ', 'tyo': 'ちょ',
|
||||
'tsu': 'つ', 'nya': 'にゃ', 'nyu': 'にゅ', 'nyo': 'にょ',
|
||||
'tsu': 'つ', 'dzu': 'づ', 'nya': 'にゃ', 'nyu': 'にゅ', 'nyo': 'にょ',
|
||||
'hya': 'ひゃ', 'hyu': 'ひゅ', 'hyo': 'ひょ',
|
||||
'bya': 'びゃ', 'byu': 'びゅ', 'byo': 'びょ',
|
||||
'pya': 'ぴゃ', 'pyu': 'ぴゅ', 'pyo': 'ぴょ',
|
||||
|
|
@ -49,7 +49,7 @@ const _romaji = {
|
|||
'la': 'ら', 'li': 'り', 'lu': 'る', 'le': 'れ', 'lo': 'ろ',
|
||||
'wa': 'わ', 'wi': 'うぃ', 'we': 'うぇ', 'wo': 'を',
|
||||
'va': 'ゔぁ', 'vi': 'ゔぃ', 'vu': 'ゔ', 've': 'ゔぇ', 'vo': 'ゔぉ',
|
||||
'nn': 'ん', "n'": 'ん',
|
||||
"n'": 'ん', 'n’': 'ん',
|
||||
'xa': 'ぁ', 'xi': 'ぃ', 'xu': 'ぅ', 'xe': 'ぇ', 'xo': 'ぉ',
|
||||
// One letter.
|
||||
'a': 'あ', 'i': 'い', 'u': 'う', 'e': 'え', 'o': 'お', '-': 'ー',
|
||||
|
|
@ -57,15 +57,30 @@ const _romaji = {
|
|||
|
||||
/// [text] with the romaji typed in it turned into hiragana as far as they
|
||||
/// spell kana (`taberu` is `たべる`): a doubled consonant is a small っ
|
||||
/// (`kitte`), `n` before a consonant ん. What can still become more kana is
|
||||
/// left as typed (`tab` is `たb`, a last `n` stays `n`), unless [finished].
|
||||
/// Kana and anything else stay as they are.
|
||||
/// (`kitte`), `n` before a consonant ん. A doubled `n` before a vowel or
|
||||
/// `y` is ん and the n-row, as Hepburn writes it (`onna` is おんな,
|
||||
/// `konnichiwa` こんにちは); ん before a vowel is `n'` (`kin'en`), or `nn`
|
||||
/// typed once more (`konnnichiha`), as keyboards spell it. What can still
|
||||
/// become more kana is left as typed (`tab` is `たb`, a last `n` or `nn`
|
||||
/// stays), unless [finished]. Kana and anything else stay as they are.
|
||||
String romajiToHiragana(String text, {bool finished = false}) {
|
||||
final out = StringBuffer();
|
||||
final lower = text.toLowerCase();
|
||||
var i = 0;
|
||||
while (i < lower.length) {
|
||||
final rest = lower.substring(i);
|
||||
if (rest.startsWith('nn')) {
|
||||
final after = rest.length > 2 ? rest[2] : null;
|
||||
if (after == null && !finished) {
|
||||
// The next letter decides: おんな or おんあ.
|
||||
out.write(text.substring(i));
|
||||
break;
|
||||
}
|
||||
out.write('ん');
|
||||
// Before a vowel or y the second n starts the next kana.
|
||||
i += after != null && 'aiueoy'.contains(after) ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
String? kana;
|
||||
var length = 0;
|
||||
for (final size in const [3, 2, 1]) {
|
||||
|
|
@ -82,8 +97,10 @@ String romajiToHiragana(String text, {bool finished = false}) {
|
|||
}
|
||||
final char = rest[0];
|
||||
final next = rest.length > 1 ? rest[1] : null;
|
||||
if (char == 'n' && next != null && !"aiueoyn'".contains(next)) {
|
||||
// ん before another consonant.
|
||||
if ((char == 'n' && next != null && !"aiueoyn'’".contains(next)) ||
|
||||
(char == 'm' && next != null && 'bpm'.contains(next))) {
|
||||
// ん before another consonant, written m before b, p and m as
|
||||
// Hepburn does (`shimbun`, `tempura`).
|
||||
out.write('ん');
|
||||
i++;
|
||||
} else if (next == char && 'bcdfghjkmpqrstvwxyz'.contains(char)) {
|
||||
|
|
@ -106,9 +123,9 @@ String romajiToHiragana(String text, {bool finished = false}) {
|
|||
}
|
||||
|
||||
/// Turns romaji into hiragana as they are typed, for answers in kana on a
|
||||
/// keyboard without Japanese: what can't be kana yet stays as typed, the
|
||||
/// caret at the end. Text being composed by the device's own Japanese input
|
||||
/// is left to it.
|
||||
/// keyboard without Japanese: what can't be kana yet stays as typed, and
|
||||
/// the caret stays where the person types, also in the middle. Text being
|
||||
/// composed by the device's own Japanese input is left to it.
|
||||
class const KanaInputFormatter() extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
|
|
@ -118,13 +135,47 @@ class const KanaInputFormatter() extends TextInputFormatter {
|
|||
if (newValue.composing.isValid && !newValue.composing.isCollapsed) {
|
||||
return newValue;
|
||||
}
|
||||
final kana = romajiToHiragana(newValue.text);
|
||||
if (kana == newValue.text) {
|
||||
final text = newValue.text;
|
||||
final caret = newValue.selection.isValid && newValue.selection.isCollapsed
|
||||
? newValue.selection.baseOffset.clamp(0, text.length)
|
||||
: text.length;
|
||||
// What is before the caret and after it, each on its own: the romaji
|
||||
// typed is right before the caret, and the caret goes after its kana.
|
||||
final before = romajiToHiragana(text.substring(0, caret));
|
||||
final after = romajiToHiragana(text.substring(caret));
|
||||
if (before + after == text) {
|
||||
return newValue;
|
||||
}
|
||||
return TextEditingValue(
|
||||
text: kana,
|
||||
selection: TextSelection.collapsed(offset: kana.length),
|
||||
text: before + after,
|
||||
selection: TextSelection.collapsed(offset: before.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ways [typed] may be read as kana, finished: long vowels with
|
||||
/// macrons spelt both ways (`ō` as `ou` and as `oo`), so `tōkyō` reads
|
||||
/// とうきょう and `ōkii` おおきい; katakana as hiragana, without spaces.
|
||||
Set<String> typedReadings(String typed) => {
|
||||
for (final long in const [
|
||||
{'ā': 'aa', 'ī': 'ii', 'ū': 'uu', 'ē': 'ei', 'ō': 'ou'},
|
||||
{'ā': 'aa', 'ī': 'ii', 'ū': 'uu', 'ē': 'ee', 'ō': 'oo'},
|
||||
])
|
||||
readingKey(
|
||||
romajiToHiragana(
|
||||
[for (final char in typed.split('')) long[char] ?? char].join(),
|
||||
finished: true,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
/// [reading] as readings are compared: in hiragana, without spaces.
|
||||
String readingKey(String reading) =>
|
||||
katakanaToHiragana(reading).replaceAll(RegExp(r'\s+'), '');
|
||||
|
||||
/// The readings a card's reading field gives, one or several (`いく、ゆく`),
|
||||
/// as compared ([readingKey]).
|
||||
Set<String> givenReadings(String field) => {
|
||||
for (final reading in field.split(RegExp('[、,,;;//]')))
|
||||
if (readingKey(reading).isNotEmpty) readingKey(reading),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -173,10 +173,11 @@ class _Field(
|
|||
}
|
||||
}
|
||||
|
||||
/// A ruby: each base text with its reading (`<rt>`) as furigana on it;
|
||||
/// the parentheses for where rubies aren't shown (`<rp>`) left out.
|
||||
/// A ruby: each base text with its reading (`<rt>`) as furigana on it,
|
||||
/// keeping what it is marked with inside (`<b>` on a cloze's answer); the
|
||||
/// parentheses for where rubies aren't shown (`<rp>`) left out.
|
||||
void _ruby(dom.Element ruby) {
|
||||
final base = StringBuffer();
|
||||
final base = <dom.Node>[];
|
||||
for (final node in ruby.nodes) {
|
||||
if (node is dom.Element && node.localName == 'rt') {
|
||||
final reading = node.text.trim();
|
||||
|
|
@ -184,14 +185,14 @@ class _Field(
|
|||
if (reading.isNotEmpty) {
|
||||
_attributes = {..._attributes, rubyKey: reading};
|
||||
}
|
||||
_text(base.toString());
|
||||
_visit(base);
|
||||
_attributes = outer;
|
||||
base.clear();
|
||||
} else if (node is! dom.Element || node.localName != 'rp') {
|
||||
base.write(node.text);
|
||||
base.add(node);
|
||||
}
|
||||
}
|
||||
_text(base.toString());
|
||||
_visit(base);
|
||||
}
|
||||
|
||||
void _list(dom.Element list, String type) {
|
||||
|
|
|
|||
|
|
@ -465,9 +465,14 @@ class StudySessionBloc(
|
|||
!_options.quizVocabulary &&
|
||||
card.content.type == CardType.vocabulary &&
|
||||
card.content.reading.trim().isNotEmpty &&
|
||||
// A word written in kana (テレビ) is its own reading.
|
||||
_kanji.hasMatch(card.content.front.plainText) &&
|
||||
!card.content.usuallyKana &&
|
||||
!_asksBack(card);
|
||||
|
||||
/// A Han character, as kanji are written.
|
||||
static final _kanji = RegExp(r'[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]');
|
||||
|
||||
/// How far ahead a card in its learning steps comes early, once nothing
|
||||
/// else is due in the round.
|
||||
static const _learnAhead = Duration(minutes: 20);
|
||||
|
|
|
|||
|
|
@ -358,13 +358,11 @@ class _TypedReadingBodyState() extends State<TypedReadingBody> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
/// What was typed as a reading: in hiragana, without spaces, a last `n`
|
||||
/// as ん.
|
||||
String get _answer => _normalized(_typed.text);
|
||||
|
||||
static String _normalized(String reading) => katakanaToHiragana(
|
||||
romajiToHiragana(reading, finished: true),
|
||||
).replaceAll(RegExp(r'\s+'), '');
|
||||
/// Whether what was typed is the card's reading, or one of them: read
|
||||
/// in each way it can be ([typedReadings]).
|
||||
bool get _right => typedReadings(
|
||||
_typed.text,
|
||||
).any(givenReadings(widget.card.content.reading).contains);
|
||||
|
||||
void _check() {
|
||||
if (_typed.text.trim().isNotEmpty && !widget.checked) {
|
||||
|
|
@ -377,7 +375,7 @@ class _TypedReadingBodyState() extends State<TypedReadingBody> {
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final content = widget.card.content;
|
||||
final right = _answer == _normalized(content.reading);
|
||||
final right = _right;
|
||||
final mark = right ? theme.extras.success : theme.colorScheme.error;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -403,12 +401,20 @@ class _TypedReadingBodyState() extends State<TypedReadingBody> {
|
|||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge,
|
||||
inputFormatters: const [KanaInputFormatter()],
|
||||
// Romaji as typed: no corrections, suggestions or
|
||||
// curly quotes (n' is ん).
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
smartDashesType: SmartDashesType.disabled,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.studyTypedHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _check(),
|
||||
// Done with nothing typed keeps the field, and its
|
||||
// keyboard, rather than letting go.
|
||||
onEditingComplete: _check,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ class _StudyLockState() extends State<StudyLock> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// A field being typed in (a reading) lets go, and its keyboard with
|
||||
// it: nothing behind the lock answers a key.
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (_pomodoro.state.run?.isRunning ?? false) {
|
||||
_paused = true;
|
||||
_pomodoro.add(const PomodoroEventPauseRequested());
|
||||
|
|
|
|||
|
|
@ -101,6 +101,46 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('writes a reading once over text formatted in parts, each part '
|
||||
'keeping its marks; the reading as HTML text', () {
|
||||
expect(
|
||||
markdown([
|
||||
text('paragraph', [
|
||||
{
|
||||
'insert': '漢',
|
||||
'attributes': {'ruby': 'か<ん>じ', 'bold': true},
|
||||
},
|
||||
{
|
||||
'insert': '字',
|
||||
'attributes': {'ruby': 'か<ん>じ'},
|
||||
},
|
||||
]),
|
||||
]),
|
||||
'<ruby>**漢**字<rt>か<ん>じ</rt></ruby>',
|
||||
);
|
||||
});
|
||||
|
||||
test('escapes what Markdown would read as its own syntax; code as it '
|
||||
'is, and blank lines kept in code blocks', () {
|
||||
expect(
|
||||
markdown([
|
||||
plain('paragraph', '1990. A *star* was _born_ <here> | there'),
|
||||
text('paragraph', [
|
||||
{
|
||||
'insert': 'a*b',
|
||||
'attributes': {'code': true},
|
||||
},
|
||||
]),
|
||||
plain('paragraph', 'Fill in: ___'),
|
||||
]),
|
||||
[
|
||||
r'1990\. A \*star\* was \_born\_ \<here\> \| there',
|
||||
'`a*b`',
|
||||
r'Fill in: \_\_\_',
|
||||
].join('\n\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test("links the person's images and sounds to their files, other images "
|
||||
'to where they are', () {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -186,4 +189,51 @@ void main() {
|
|||
expect(reading.center.dx, moreOrLessEquals(text.center.dx, epsilon: 1));
|
||||
expect(tester.getSize(find.text('かんじ')).height, lessThan(text.height));
|
||||
});
|
||||
|
||||
testWidgets('a reading over text formatted in parts is drawn once, over '
|
||||
'all of it', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{
|
||||
'insert': '漢',
|
||||
'attributes': {'ruby': 'かんじ', 'bold': true},
|
||||
},
|
||||
{
|
||||
'insert': '字',
|
||||
'attributes': {'ruby': 'かんじ'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(find.text('かんじ'), findsOneWidget);
|
||||
expect(find.text('漢字'), findsOneWidget);
|
||||
});
|
||||
|
||||
test("text typed right after a reading's text goes without the reading, "
|
||||
'as after a link', () {
|
||||
// The editor's styles set this up as they are made.
|
||||
withRuby(
|
||||
(context, node, index, text, before, after) => before,
|
||||
editing: true,
|
||||
);
|
||||
final delta = Delta()
|
||||
..insert('漢字', attributes: {rubyKey: 'かんじ'})
|
||||
..insert('を');
|
||||
|
||||
expect(delta.sliceAttributes(2)?[rubyKey], isNull);
|
||||
expect(delta.sliceAttributes(1)?[rubyKey], 'かんじ');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,34 @@ void main() {
|
|||
expect(romajiToHiragana('たべru'), 'たべる');
|
||||
});
|
||||
|
||||
test('Hepburn as learners type it: a doubled n before a vowel or y is ん '
|
||||
'and the n-row, m before b, p and m is ん; keyboards spell ん before '
|
||||
"a vowel as n' or nn typed once more", () {
|
||||
expect(romajiToHiragana('onna'), 'おんな');
|
||||
expect(romajiToHiragana('minna'), 'みんな');
|
||||
expect(romajiToHiragana('konnichiwa'), 'こんにちわ');
|
||||
expect(romajiToHiragana('annai'), 'あんない');
|
||||
expect(romajiToHiragana('shinnyuusei'), 'しんにゅうせい');
|
||||
expect(romajiToHiragana('konnnichiha'), 'こんにちは');
|
||||
expect(romajiToHiragana("kon'ya"), 'こんや');
|
||||
expect(romajiToHiragana('kon’ya'), 'こんや');
|
||||
expect(romajiToHiragana('shimbun', finished: true), 'しんぶん');
|
||||
expect(romajiToHiragana('tempura'), 'てんぷら');
|
||||
expect(romajiToHiragana('tsudzuku'), 'つづく');
|
||||
// A last nn waits for the next letter, unless that was the last.
|
||||
expect(romajiToHiragana('onn'), 'おnn');
|
||||
expect(romajiToHiragana('honn', finished: true), 'ほん');
|
||||
});
|
||||
|
||||
test('a typed reading counts in each way it can be read: macrons as two '
|
||||
'vowels either way; a reading field may give several', () {
|
||||
expect(typedReadings('tōkyō'), contains('とうきょう'));
|
||||
expect(typedReadings('ōkii'), contains('おおきい'));
|
||||
expect(typedReadings('タベル'), {'たべる'});
|
||||
expect(givenReadings('いく、ゆく'), {'いく', 'ゆく'});
|
||||
expect(givenReadings(' カード '), {'かーど'});
|
||||
});
|
||||
|
||||
test('katakana count as hiragana', () {
|
||||
expect(katakanaToHiragana('タベル'), 'たべる');
|
||||
expect(katakanaToHiragana('ラーメン'), 'らーめん');
|
||||
|
|
@ -34,6 +62,17 @@ void main() {
|
|||
expect(typed.text, 'たべ');
|
||||
expect(typed.selection, const TextSelection.collapsed(offset: 2));
|
||||
|
||||
// Typed in the middle, the caret stays after what was typed there.
|
||||
final middle = formatter.formatEditUpdate(
|
||||
TextEditingValue.empty,
|
||||
const TextEditingValue(
|
||||
text: 'たkaべ',
|
||||
selection: TextSelection.collapsed(offset: 3),
|
||||
),
|
||||
);
|
||||
expect(middle.text, 'たかべ');
|
||||
expect(middle.selection, const TextSelection.collapsed(offset: 2));
|
||||
|
||||
const composing = TextEditingValue(
|
||||
text: 'tabe',
|
||||
composing: TextRange(start: 0, end: 4),
|
||||
|
|
|
|||
|
|
@ -184,6 +184,24 @@ void main() {
|
|||
{'insert': 'を読む'},
|
||||
]);
|
||||
});
|
||||
test('formatting inside a ruby stays, with the reading over it', () {
|
||||
final content = ankiContent([
|
||||
'<ruby><b>漢</b>字<rt>かんじ</rt></ruby>',
|
||||
], assetOf: files.get);
|
||||
final document = content.editorJson['document']! as Map<String, Object?>;
|
||||
final line = (document['children']! as List).single as Map;
|
||||
|
||||
expect((line['data']! as Map)['delta'], [
|
||||
{
|
||||
'insert': '漢',
|
||||
'attributes': {'ruby': 'かんじ', 'bold': true},
|
||||
},
|
||||
{
|
||||
'insert': '字',
|
||||
'attributes': {'ruby': 'かんじ'},
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
extension on Map<String, String> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue