feat(editor): text above is named by the language, shown on hover or a long press
Furigana was the only name for text set above text, though it carries pinyin, a pronunciation or a translation as well. Its button and dialog now say furigana in a Japanese studyset, pinyin in a Chinese one, and text above elsewhere, each with an example of its own: the studyset's languages reach the core's editors through ContentLanguages, which the studyset's language scope now sets. Read-only, the text above shows only under the pointer or after a long press (another hides it), so the text can be read without it first; its room stays, so no line moves. While editing, it shows over the pointer above its dotted text; with the caret inside such text, the button selects all of it and edits what it has, so it can be seen and changed without finding its ends. 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
fcee3c2670
commit
a0b4fa969c
12 changed files with 487 additions and 79 deletions
|
|
@ -790,7 +790,9 @@
|
|||
"formatUnderline": "Unterstrichen",
|
||||
"formatSuperscript": "Hochgestellt",
|
||||
"formatSubscript": "Tiefgestellt",
|
||||
"formatRuby": "Furigana (eine Lesung über dem Text)",
|
||||
"formatRuby": "Text darüber (Aussprache, Übersetzung)",
|
||||
"formatRubyFurigana": "Furigana (eine Lesung über dem Text)",
|
||||
"formatRubyPinyin": "Pinyin (über den Schriftzeichen)",
|
||||
"formatLink": "Link",
|
||||
"linkTitle": "Link",
|
||||
"linkText": "Text",
|
||||
|
|
@ -798,7 +800,12 @@
|
|||
"linkAddressHint": "https://…",
|
||||
"linkSave": "Speichern",
|
||||
"linkRemove": "Link entfernen",
|
||||
"rubyTitle": "Furigana",
|
||||
"rubyTitle": "Text darüber",
|
||||
"rubyTitleFurigana": "Furigana",
|
||||
"rubyTitlePinyin": "Pinyin",
|
||||
"rubyText": "Text",
|
||||
"rubyHint": "z. B. eine Aussprache oder eine Übersetzung",
|
||||
"rubyHintPinyin": "Mit Tonzeichen, z. B. hànzì",
|
||||
"rubyReading": "Lesung",
|
||||
"rubyReadingHint": "In Kana, z. B. かんじ",
|
||||
"rubySave": "Speichern",
|
||||
|
|
|
|||
|
|
@ -888,7 +888,9 @@
|
|||
"formatUnderline": "Underline",
|
||||
"formatSuperscript": "Superscript",
|
||||
"formatSubscript": "Subscript",
|
||||
"formatRuby": "Furigana (a reading above the text)",
|
||||
"formatRuby": "Text above (a pronunciation, a translation)",
|
||||
"formatRubyFurigana": "Furigana (a reading above the text)",
|
||||
"formatRubyPinyin": "Pinyin (above the characters)",
|
||||
"formatLink": "Link",
|
||||
"linkTitle": "Link",
|
||||
"linkText": "Text",
|
||||
|
|
@ -896,9 +898,14 @@
|
|||
"linkAddressHint": "https://…",
|
||||
"linkSave": "Save",
|
||||
"linkRemove": "Remove link",
|
||||
"rubyTitle": "Furigana",
|
||||
"rubyTitle": "Text above",
|
||||
"rubyTitleFurigana": "Furigana",
|
||||
"rubyTitlePinyin": "Pinyin",
|
||||
"rubyText": "Text",
|
||||
"rubyHint": "e.g. a pronunciation or a translation",
|
||||
"rubyReading": "Reading",
|
||||
"rubyReadingHint": "In kana, e.g. かんじ",
|
||||
"rubyHintPinyin": "With tone marks, e.g. hànzì",
|
||||
"rubySave": "Save",
|
||||
"rubyRemove": "Remove",
|
||||
"vocabularyReading": "Reading",
|
||||
|
|
|
|||
|
|
@ -4645,9 +4645,21 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @formatRuby.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Furigana (a reading above the text)'**
|
||||
/// **'Text above (a pronunciation, a translation)'**
|
||||
String get formatRuby;
|
||||
|
||||
/// No description provided for @formatRubyFurigana.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Furigana (a reading above the text)'**
|
||||
String get formatRubyFurigana;
|
||||
|
||||
/// No description provided for @formatRubyPinyin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pinyin (above the characters)'**
|
||||
String get formatRubyPinyin;
|
||||
|
||||
/// No description provided for @formatLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -4693,9 +4705,33 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @rubyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Furigana'**
|
||||
/// **'Text above'**
|
||||
String get rubyTitle;
|
||||
|
||||
/// No description provided for @rubyTitleFurigana.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Furigana'**
|
||||
String get rubyTitleFurigana;
|
||||
|
||||
/// No description provided for @rubyTitlePinyin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pinyin'**
|
||||
String get rubyTitlePinyin;
|
||||
|
||||
/// No description provided for @rubyText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Text'**
|
||||
String get rubyText;
|
||||
|
||||
/// No description provided for @rubyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'e.g. a pronunciation or a translation'**
|
||||
String get rubyHint;
|
||||
|
||||
/// No description provided for @rubyReading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -4708,6 +4744,12 @@ abstract class AppLocalizations {
|
|||
/// **'In kana, e.g. かんじ'**
|
||||
String get rubyReadingHint;
|
||||
|
||||
/// No description provided for @rubyHintPinyin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'With tone marks, e.g. hànzì'**
|
||||
String get rubyHintPinyin;
|
||||
|
||||
/// No description provided for @rubySave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -2885,7 +2885,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get formatSubscript => 'Tiefgestellt';
|
||||
|
||||
@override
|
||||
String get formatRuby => 'Furigana (eine Lesung über dem Text)';
|
||||
String get formatRuby => 'Text darüber (Aussprache, Übersetzung)';
|
||||
|
||||
@override
|
||||
String get formatRubyFurigana => 'Furigana (eine Lesung über dem Text)';
|
||||
|
||||
@override
|
||||
String get formatRubyPinyin => 'Pinyin (über den Schriftzeichen)';
|
||||
|
||||
@override
|
||||
String get formatLink => 'Link';
|
||||
|
|
@ -2909,7 +2915,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get linkRemove => 'Link entfernen';
|
||||
|
||||
@override
|
||||
String get rubyTitle => 'Furigana';
|
||||
String get rubyTitle => 'Text darüber';
|
||||
|
||||
@override
|
||||
String get rubyTitleFurigana => 'Furigana';
|
||||
|
||||
@override
|
||||
String get rubyTitlePinyin => 'Pinyin';
|
||||
|
||||
@override
|
||||
String get rubyText => 'Text';
|
||||
|
||||
@override
|
||||
String get rubyHint => 'z. B. eine Aussprache oder eine Übersetzung';
|
||||
|
||||
@override
|
||||
String get rubyReading => 'Lesung';
|
||||
|
|
@ -2917,6 +2935,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get rubyReadingHint => 'In Kana, z. B. かんじ';
|
||||
|
||||
@override
|
||||
String get rubyHintPinyin => 'Mit Tonzeichen, z. B. hànzì';
|
||||
|
||||
@override
|
||||
String get rubySave => 'Speichern';
|
||||
|
||||
|
|
|
|||
|
|
@ -2863,7 +2863,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get formatSubscript => 'Subscript';
|
||||
|
||||
@override
|
||||
String get formatRuby => 'Furigana (a reading above the text)';
|
||||
String get formatRuby => 'Text above (a pronunciation, a translation)';
|
||||
|
||||
@override
|
||||
String get formatRubyFurigana => 'Furigana (a reading above the text)';
|
||||
|
||||
@override
|
||||
String get formatRubyPinyin => 'Pinyin (above the characters)';
|
||||
|
||||
@override
|
||||
String get formatLink => 'Link';
|
||||
|
|
@ -2887,7 +2893,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get linkRemove => 'Remove link';
|
||||
|
||||
@override
|
||||
String get rubyTitle => 'Furigana';
|
||||
String get rubyTitle => 'Text above';
|
||||
|
||||
@override
|
||||
String get rubyTitleFurigana => 'Furigana';
|
||||
|
||||
@override
|
||||
String get rubyTitlePinyin => 'Pinyin';
|
||||
|
||||
@override
|
||||
String get rubyText => 'Text';
|
||||
|
||||
@override
|
||||
String get rubyHint => 'e.g. a pronunciation or a translation';
|
||||
|
||||
@override
|
||||
String get rubyReading => 'Reading';
|
||||
|
|
@ -2895,6 +2913,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get rubyReadingHint => 'In kana, e.g. かんじ';
|
||||
|
||||
@override
|
||||
String get rubyHintPinyin => 'With tone marks, e.g. hànzì';
|
||||
|
||||
@override
|
||||
String get rubySave => 'Save';
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Formats the selection of [editorState]: alignment, bold, italic,
|
||||
/// underline, a link, superscript and subscript, furigana, and with [onFormula] formulas
|
||||
/// underline, a link, superscript and subscript, text above (furigana),
|
||||
/// and with [onFormula] formulas
|
||||
/// within the line. Each button shows whether the selection has its format
|
||||
/// already. With [onInsert], an Insert button comes first, for blocks; the
|
||||
/// [pinned] blocks have buttons at the end, outside the scrolling, so they
|
||||
|
|
@ -204,7 +205,7 @@ class _FormatBarState() extends State<FormatBar> {
|
|||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.languages,
|
||||
tooltip: l10n.formatRuby,
|
||||
tooltip: rubyButtonName(context),
|
||||
active:
|
||||
editorState.selection != null &&
|
||||
editorState.getDeltaAttributeValueInSelection<String>(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Edits [initialContent] and reports each change as new [RichContent].
|
||||
|
|
@ -85,6 +86,8 @@ class _RichContentEditorState() extends State<RichContentEditor> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
// Its text with text above may be under the pointer.
|
||||
hideHoverReading();
|
||||
_focus.removeListener(_onFocus);
|
||||
_ownFocus?.dispose();
|
||||
unawaited(_changes.cancel());
|
||||
|
|
|
|||
|
|
@ -2,30 +2,80 @@ 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:halkyon_learn_flutter/src/core/widget/content_languages.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
// Furigana (ruby): the reading of text within a line, kept on the text's
|
||||
// attributes in the document as `rubyKey` (content_blocks.dart). Lessons,
|
||||
// example sentences and notes carry readings this way, word by word.
|
||||
// Text above (ruby): text set small above text within a line, kept on the
|
||||
// text's attributes in the document as `rubyKey` (content_blocks.dart): a
|
||||
// reading (furigana over Japanese, pinyin over Chinese), a pronunciation, a
|
||||
// translation. Lessons, example sentences and notes carry readings this
|
||||
// way, word by word. Read-only, it shows under the pointer or after a long
|
||||
// press, so the text can be read without it first; its room is kept.
|
||||
|
||||
/// How large a reading is beside the text it reads.
|
||||
const _rubyScale = 0.5;
|
||||
|
||||
/// Gives the text selected in [editorState] a reading, asked for starting
|
||||
/// from the one it has; an empty one takes it away. Nothing without text
|
||||
/// selected: a reading belongs to the text it reads.
|
||||
/// What text above is called where content is written in a language that
|
||||
/// has a name for it: furigana over Japanese, pinyin over Chinese, the
|
||||
/// front's language first.
|
||||
enum _RubyKind() {
|
||||
furigana,
|
||||
pinyin,
|
||||
any;
|
||||
|
||||
static _RubyKind of(BuildContext context) {
|
||||
final (:front, :back) = ContentLanguages.of(context);
|
||||
for (final tag in [front, back]) {
|
||||
switch (tag?.split('-').first.toLowerCase()) {
|
||||
case 'ja':
|
||||
return furigana;
|
||||
case 'zh':
|
||||
return pinyin;
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
}
|
||||
|
||||
/// The format bar's name for text above, in the terms of the language of
|
||||
/// the content around [context].
|
||||
String rubyButtonName(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return switch (_RubyKind.of(context)) {
|
||||
_RubyKind.furigana => l10n.formatRubyFurigana,
|
||||
_RubyKind.pinyin => l10n.formatRubyPinyin,
|
||||
_RubyKind.any => l10n.formatRuby,
|
||||
};
|
||||
}
|
||||
|
||||
/// Gives the text selected in [editorState] text above, asked for starting
|
||||
/// from what it has; empty takes it away. With the caret in text that has
|
||||
/// text above (or right after it), that whole text is selected and edited,
|
||||
/// so what is there can be seen and changed without finding its ends.
|
||||
/// Nothing at a caret elsewhere: text above belongs to the text it is over.
|
||||
Future<void> editRuby(BuildContext context, EditorState editorState) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || selection.isCollapsed) {
|
||||
var selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
if (selection.isCollapsed) {
|
||||
final run = _runAround(editorState, selection.start);
|
||||
if (run == null) {
|
||||
return;
|
||||
}
|
||||
selection = editorState.selection = run;
|
||||
}
|
||||
hideHoverReading();
|
||||
final current = editorState.getDeltaAttributeValueInSelection<String>(
|
||||
rubyKey,
|
||||
selection,
|
||||
);
|
||||
final kind = _RubyKind.of(context);
|
||||
final reading = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => _ReadingDialog(initial: current ?? ''),
|
||||
builder: (dialogContext) =>
|
||||
_ReadingDialog(initial: current ?? '', kind: kind),
|
||||
);
|
||||
editorState.selection = selection;
|
||||
if (reading == null) {
|
||||
|
|
@ -69,6 +119,9 @@ TextSpanDecoratorForAttribute withRuby(
|
|||
mouseCursor: span.mouseCursor,
|
||||
semanticsLabel: span.semanticsLabel,
|
||||
locale: span.locale,
|
||||
// What it says shows over the pointer.
|
||||
onEnter: (event) => _showHoverReading(context, reading, event.position),
|
||||
onExit: (_) => hideHoverReading(),
|
||||
);
|
||||
}
|
||||
final run = _runAt(node.delta, index, reading);
|
||||
|
|
@ -92,6 +145,7 @@ TextSpanDecoratorForAttribute withRuby(
|
|||
),
|
||||
reading: reading,
|
||||
style: style,
|
||||
hidden: true,
|
||||
);
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
|
|
@ -107,6 +161,105 @@ TextSpanDecoratorForAttribute withRuby(
|
|||
};
|
||||
}
|
||||
|
||||
/// The text above under the pointer while editing, shown over it.
|
||||
OverlayEntry? _hoverReading;
|
||||
|
||||
void _showHoverReading(BuildContext context, String reading, Offset at) {
|
||||
hideHoverReading();
|
||||
final overlay = Overlay.maybeOf(context, rootOverlay: true);
|
||||
final box = overlay?.context.findRenderObject();
|
||||
if (overlay == null || box is! RenderBox) {
|
||||
return;
|
||||
}
|
||||
final position = box.globalToLocal(at);
|
||||
final theme = Theme.of(context);
|
||||
overlay.insert(
|
||||
_hoverReading = OverlayEntry(
|
||||
builder: (overlayContext) => Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy - 12,
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, -1),
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.inverseSurface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
reading,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Takes away the text above shown over the pointer: when the pointer
|
||||
/// leaves its text, and when the editor goes, which the text doesn't learn.
|
||||
void hideHoverReading() {
|
||||
_hoverReading?.remove();
|
||||
_hoverReading = null;
|
||||
}
|
||||
|
||||
/// The text with text above around the caret [at] in [editorState]: the
|
||||
/// pieces with the same text above as the one the caret is in, else the one
|
||||
/// it is right after, else right before; null where there is none.
|
||||
Selection? _runAround(EditorState editorState, Position at) {
|
||||
final delta = editorState.getNodeAtPath(at.path)?.delta;
|
||||
if (delta == null) {
|
||||
return null;
|
||||
}
|
||||
final pieces = <({int start, int end, String? reading})>[];
|
||||
var offset = 0;
|
||||
for (final op in delta) {
|
||||
pieces.add((
|
||||
start: offset,
|
||||
end: offset + op.length,
|
||||
reading: switch (op.attributes?[rubyKey]) {
|
||||
final String reading when reading.isNotEmpty => reading,
|
||||
_ => null,
|
||||
},
|
||||
));
|
||||
offset += op.length;
|
||||
}
|
||||
int? find(bool Function(({int start, int end, String? reading})) test) {
|
||||
final index = pieces.indexWhere(
|
||||
(piece) => piece.reading != null && test(piece),
|
||||
);
|
||||
return index < 0 ? null : index;
|
||||
}
|
||||
|
||||
final hit =
|
||||
find((piece) => piece.start < at.offset && at.offset < piece.end) ??
|
||||
find((piece) => piece.end == at.offset) ??
|
||||
find((piece) => piece.start == at.offset);
|
||||
if (hit == null) {
|
||||
return null;
|
||||
}
|
||||
final reading = pieces[hit].reading;
|
||||
var first = hit;
|
||||
while (first > 0 && pieces[first - 1].reading == reading) {
|
||||
first--;
|
||||
}
|
||||
var last = hit;
|
||||
while (last < pieces.length - 1 && pieces[last + 1].reading == reading) {
|
||||
last++;
|
||||
}
|
||||
return Selection(
|
||||
start: Position(path: at.path, offset: pieces[first].start),
|
||||
end: Position(path: at.path, offset: pieces[last].end),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -169,42 +322,76 @@ TextStyle _styled(TextStyle style, Attributes? attributes) => style.copyWith(
|
|||
/// [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.
|
||||
/// [hidden], the reading shows only under the pointer or once a long press
|
||||
/// shows it (another hides it), its room kept so the lines don't move.
|
||||
class const RubyText({
|
||||
required final InlineSpan base,
|
||||
required final String reading,
|
||||
required final TextStyle style,
|
||||
final bool hidden = false,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
label: '${base.toPlainText()} ($reading)',
|
||||
excludeSemantics: true,
|
||||
State<RubyText> createState() => _RubyTextState();
|
||||
}
|
||||
|
||||
class _RubyTextState() extends State<RubyText> {
|
||||
var _hovered = false;
|
||||
var _pressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = widget.style;
|
||||
final reading = Text(
|
||||
widget.reading,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: style.copyWith(
|
||||
fontSize: (style.fontSize ?? 16) * _rubyScale,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.normal,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
);
|
||||
// Laid out from the bottom: the text comes first, so the box takes its
|
||||
// baseline, and the line's text stays level with it.
|
||||
child: Column(
|
||||
final ruby = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
verticalDirection: VerticalDirection.up,
|
||||
children: [
|
||||
Text.rich(base, style: style, textScaler: TextScaler.noScaling),
|
||||
Text(
|
||||
Text.rich(widget.base, style: style, textScaler: TextScaler.noScaling),
|
||||
if (widget.hidden)
|
||||
AnimatedOpacity(
|
||||
opacity: _hovered || _pressed ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
child: reading,
|
||||
)
|
||||
else
|
||||
reading,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: style.copyWith(
|
||||
fontSize: (style.fontSize ?? 16) * _rubyScale,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.normal,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
return Semantics(
|
||||
label: '${widget.base.toPlainText()} (${widget.reading})',
|
||||
excludeSemantics: true,
|
||||
child: !widget.hidden
|
||||
? ruby
|
||||
: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onLongPress: () => setState(() => _pressed = !_pressed),
|
||||
child: ruby,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for a reading, starting from [initial]: completes with it, empty to
|
||||
/// take the reading away, or with null when cancelled.
|
||||
class const _ReadingDialog({required final String initial})
|
||||
extends StatefulWidget {
|
||||
/// Asks for text above, starting from [initial], named as [kind] calls it:
|
||||
/// completes with it, empty to take it away, or with null when cancelled.
|
||||
class const _ReadingDialog({
|
||||
required final String initial,
|
||||
required final _RubyKind kind,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_ReadingDialog> createState() => _ReadingDialogState();
|
||||
}
|
||||
|
|
@ -221,16 +408,29 @@ class _ReadingDialogState() extends State<_ReadingDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final (title, label, hint) = switch (widget.kind) {
|
||||
_RubyKind.furigana => (
|
||||
l10n.rubyTitleFurigana,
|
||||
l10n.rubyReading,
|
||||
l10n.rubyReadingHint,
|
||||
),
|
||||
_RubyKind.pinyin => (
|
||||
l10n.rubyTitlePinyin,
|
||||
l10n.rubyTitlePinyin,
|
||||
l10n.rubyHintPinyin,
|
||||
),
|
||||
_RubyKind.any => (l10n.rubyTitle, l10n.rubyText, l10n.rubyHint),
|
||||
};
|
||||
return AlertDialog(
|
||||
title: Text(l10n.rubyTitle),
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
maxLength: TextLimits.secondary,
|
||||
buildCounter: nearLimitCounter,
|
||||
controller: _reading,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.rubyReading,
|
||||
hintText: l10n.rubyReadingHint,
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (reading) => Navigator.of(context).pop(reading),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The languages of the content below, as BCP 47 tags: a studyset's front
|
||||
/// and back, which every screen of a studyset sets. The core's editors read
|
||||
/// them to name what they offer in the language's own terms (text above
|
||||
/// Japanese is furigana).
|
||||
class const ContentLanguages({
|
||||
required final ({String? front, String? back}) languages,
|
||||
required super.child,
|
||||
super.key,
|
||||
}) extends InheritedWidget {
|
||||
/// The languages around [context]; none outside a studyset.
|
||||
static ({String? front, String? back}) of(BuildContext context) =>
|
||||
context
|
||||
.dependOnInheritedWidgetOfExactType<ContentLanguages>()
|
||||
?.languages ??
|
||||
(front: null, back: null);
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ContentLanguages oldWidget) =>
|
||||
languages != oldWidget.languages;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/app_fonts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -20,8 +21,7 @@ class const StudysetLanguageScope({
|
|||
}) extends StatelessWidget {
|
||||
/// The languages of the studyset around [context]; none outside one.
|
||||
static StudysetLanguages of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_Languages>()?.languages ??
|
||||
(front: null, back: null);
|
||||
ContentLanguages.of(context);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
|
|
@ -57,16 +57,7 @@ class const LanguageScope({
|
|||
fontFamilyFallback: fallback,
|
||||
),
|
||||
),
|
||||
child: _Languages(languages: languages, child: child),
|
||||
child: ContentLanguages(languages: languages, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _Languages({
|
||||
required final StudysetLanguages languages,
|
||||
required super.child,
|
||||
}) extends InheritedWidget {
|
||||
@override
|
||||
bool updateShouldNotify(_Languages oldWidget) =>
|
||||
languages != oldWidget.languages;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,28 +7,38 @@ import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
late RichContent latest;
|
||||
|
||||
Future<EditorState> edit(WidgetTester tester, String text) async {
|
||||
latest = RichContent.plain(text);
|
||||
/// Edits [text], or [content], written in [language] where given.
|
||||
Future<EditorState> edit(
|
||||
WidgetTester tester,
|
||||
String text, {
|
||||
RichContent? content,
|
||||
String? language,
|
||||
}) async {
|
||||
latest = content ?? RichContent.plain(text);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Scaffold(
|
||||
body: ListView(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: RichContentEditor(
|
||||
initialContent: latest,
|
||||
onChanged: (content) => latest = content,
|
||||
formatBar: true,
|
||||
home: ContentLanguages(
|
||||
languages: (front: language, back: 'en'),
|
||||
child: Scaffold(
|
||||
body: ListView(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: RichContentEditor(
|
||||
initialContent: latest,
|
||||
onChanged: (content) => latest = content,
|
||||
formatBar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -121,16 +131,16 @@ void main() {
|
|||
expect(runs()[1], {subscriptKey: true});
|
||||
});
|
||||
|
||||
testWidgets('furigana gives the selected text a reading, asked for, and '
|
||||
'takes it away again', (tester) async {
|
||||
final editorState = await edit(tester, '漢字を読む');
|
||||
testWidgets('text above is furigana in Japanese: it gives the selected '
|
||||
'text a reading, asked for, and takes it away again', (tester) async {
|
||||
final editorState = await edit(tester, '漢字を読む', language: 'ja');
|
||||
await select(tester, editorState, 0, 2);
|
||||
|
||||
await tester.ensureVisible(
|
||||
find.byTooltip('Furigana (a reading above the text)'),
|
||||
);
|
||||
await press(tester, 'Furigana (a reading above the text)');
|
||||
const furigana = 'Furigana (a reading above the text)';
|
||||
await tester.ensureVisible(find.byTooltip(furigana));
|
||||
await press(tester, furigana);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('In kana, e.g. かんじ'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).last, 'かんじ');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
|
@ -140,7 +150,7 @@ void main() {
|
|||
]);
|
||||
|
||||
await select(tester, editorState, 0, 2);
|
||||
await press(tester, 'Furigana (a reading above the text)');
|
||||
await press(tester, furigana);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(TextField, 'かんじ'), findsOneWidget);
|
||||
await tester.tap(find.text('Remove'));
|
||||
|
|
@ -148,6 +158,59 @@ void main() {
|
|||
expect(runs(), everyElement(isNull));
|
||||
});
|
||||
|
||||
testWidgets('elsewhere it is text above; with the caret inside such text '
|
||||
'the button edits all of it', (tester) async {
|
||||
final editorState = await edit(
|
||||
tester,
|
||||
'',
|
||||
content: RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'I '},
|
||||
{
|
||||
'insert': 'like',
|
||||
'attributes': {rubyKey: 'mag'},
|
||||
},
|
||||
{
|
||||
'insert': ' it',
|
||||
'attributes': {rubyKey: 'mag', 'bold': true},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await select(tester, editorState, 4, 4);
|
||||
|
||||
const textAbove = 'Text above (a pronunciation, a translation)';
|
||||
await tester.ensureVisible(find.byTooltip(textAbove));
|
||||
await press(tester, textAbove);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('e.g. a pronunciation or a translation'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextField, 'mag'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).last, 'mag es');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(runs(), [
|
||||
null,
|
||||
{rubyKey: 'mag es'},
|
||||
{rubyKey: 'mag es', 'bold': true},
|
||||
]);
|
||||
|
||||
// A caret in text without any does nothing.
|
||||
await select(tester, editorState, 1, 1);
|
||||
await press(tester, textAbove);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('centres the selected line', (tester) async {
|
||||
final editorState = await edit(tester, 'Water is H2O');
|
||||
await select(tester, editorState, 0, 5);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/gestures.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';
|
||||
|
|
@ -283,6 +284,35 @@ void main() {
|
|||
expect(reading.bottom, lessThanOrEqualTo(text.top + 1));
|
||||
expect(reading.center.dx, moreOrLessEquals(text.center.dx, epsilon: 1));
|
||||
expect(tester.getSize(find.text('かんじ')).height, lessThan(text.height));
|
||||
|
||||
// Unseen until the pointer is over it, or a long press shows it; its
|
||||
// room is kept, so the line doesn't move.
|
||||
double shown() => tester
|
||||
.widget<AnimatedOpacity>(
|
||||
find.ancestor(
|
||||
of: find.text('かんじ'),
|
||||
matching: find.byType(AnimatedOpacity),
|
||||
),
|
||||
)
|
||||
.opacity;
|
||||
expect(shown(), 0);
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: Offset.zero);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.moveTo(tester.getCenter(find.text('漢字')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(shown(), 1);
|
||||
expect(tester.getRect(find.text('漢字')), text);
|
||||
await mouse.moveTo(Offset.zero);
|
||||
await tester.pumpAndSettle();
|
||||
expect(shown(), 0);
|
||||
|
||||
await tester.longPress(find.text('漢字'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(shown(), 1);
|
||||
await tester.longPress(find.text('漢字'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(shown(), 0);
|
||||
});
|
||||
|
||||
testWidgets('a reading over text formatted in parts is drawn once, over '
|
||||
|
|
|
|||
Loading…
Reference in a new issue