the-mews/app/lib/presentation/reader/mews_page_view.dart

578 lines
21 KiB
Dart
Raw Normal View History

import 'dart:ui' as ui show TextDirection;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../l10n/strings.dart';
import '../../application/fonts.dart';
import '../../mews/document.dart';
import 'palettes.dart';
/// Renders a parsed [MewsPage] with the client's own presentation, following
/// the measurements of the default stylesheet mews-0.1.css: a 40em text
/// column, 1.5 line height, and the block margins authors expect.
class MewsPageView extends StatefulWidget {
const MewsPageView({
required this.page,
required this.palette,
required this.fontFamily,
required this.fontScale,
required this.visitedUrls,
required this.onLinkTap,
required this.onFormSubmit,
this.scrollController,
this.ownPage = false,
super.key,
});
final MewsPage page;
final MewsPalette palette;
final String fontFamily;
final double fontScale;
final Set<String> visitedUrls;
final void Function(String url) onLinkTap;
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
final ScrollController? scrollController;
/// Whether this is one of the client's own pages. Those are built from the
/// same blocks, but they are interface rather than an article, so they are
/// laid out denser and wider, rows are divided instead of bulleted, and
/// links read as controls rather than prose.
final bool ownPage;
@override
State<MewsPageView> createState() => _MewsPageViewState();
}
class _MewsPageViewState extends State<MewsPageView> {
final _recognizers = <MewsLink, TapGestureRecognizer>{};
@override
void didUpdateWidget(MewsPageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.page != widget.page) {
_disposeRecognizers();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers.values) {
recognizer.dispose();
}
_recognizers.clear();
}
@override
Widget build(BuildContext context) {
final strings = StringsScope.of(context);
final base = 17.0 * widget.fontScale; // body font-size: 106%
final palette = widget.palette;
final blocks = [
if (!widget.page.isMews)
_notice(strings.t('error-non-mews'), base, palette),
for (final block in widget.page.body) _block(context, block, base),
];
return Directionality(
textDirection: widget.page.direction == TextDirection.rtl
? ui.TextDirection.rtl
: ui.TextDirection.ltr,
// Page text is the reader's to copy; forms manage their own selection.
child: SelectionArea(
child: ColoredBox(
color: palette.background,
child: ListView(
controller: widget.scrollController,
padding: EdgeInsets.all(base),
children: [
Center(
child: ConstrainedBox(
// max-width: 40em for an article; our own pages are lists, so
// they get more room before they stop growing.
constraints:
BoxConstraints(maxWidth: (widget.ownPage ? 56 : 40) * base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: blocks,
),
),
),
],
),
),
),
);
}
Widget _notice(String text, double base, MewsPalette palette) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text(
text,
style: TextStyle(
color: palette.foreground.withValues(alpha: 0.7),
fontFamily: widget.fontFamily,
fontFamilyFallback: fallbackFor(widget.fontFamily),
fontSize: base * 0.85,
),
),
);
// Blocks -------------------------------------------------------------------
Widget _block(BuildContext context, MewsBlock block, double base) {
final palette = widget.palette;
final foreground = TextStyle(
color: palette.foreground,
fontFamily: widget.fontFamily,
fontFamilyFallback: fallbackFor(widget.fontFamily),
fontSize: base,
height: 1.5,
);
return switch (block) {
HeadingBlock(:final level, :final inline) => Padding(
padding: EdgeInsets.only(
top: level == 1
? base * 0.5
: (widget.ownPage ? base : base * 1.6),
bottom: base * 0.5,
),
child: Text.rich(
TextSpan(children: _inline(inline)),
style: foreground.copyWith(
fontWeight: FontWeight.bold,
height: 1.25,
fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1],
// A section label on our own pages, not a document heading.
color: widget.ownPage && level > 1
? palette.foreground.withValues(alpha: 0.7)
: null,
letterSpacing: widget.ownPage && level > 1 ? 0.4 : null,
),
),
),
ParagraphBlock(:final inline) => Padding(
padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base),
child: Text.rich(TextSpan(children: _inline(inline)), style: foreground),
),
AddressBlock(:final inline) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text.rich(
TextSpan(children: _inline(inline)),
style: foreground.copyWith(fontStyle: FontStyle.italic),
),
),
QuoteBlock(:final children) => Container(
margin: EdgeInsets.only(bottom: base),
decoration: BoxDecoration(
border: BorderDirectional(start: BorderSide(color: palette.border, width: 3)),
),
padding: EdgeInsetsDirectional.only(start: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [for (final child in children) _block(context, child, base)],
),
),
PreBlock(:final text) => Container(
margin: EdgeInsets.only(bottom: base),
color: palette.preBackground,
padding: EdgeInsets.all(base * 0.75),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
text,
style: foreground.copyWith(
fontFamily: 'monospace',
fontSize: base * 0.9,
),
),
),
),
RuleBlock() => Padding(
padding: EdgeInsets.symmetric(
vertical: widget.ownPage ? base * 0.6 : base * 2),
child: Divider(color: palette.border, height: 1),
),
// Rows on our own pages: separated by a line rather than marked with a
// bullet, so a list of bookmarks or settings reads as a list of rows.
ListBlock(:final items) when widget.ownPage => Padding(
padding: EdgeInsets.only(bottom: base * 0.35),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0) Divider(color: palette.border, height: 1),
Padding(
padding: EdgeInsets.only(top: base * 0.45),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in items[i].blocks)
_block(context, child, base),
],
),
),
],
],
),
),
ListBlock(:final ordered, :final start, :final items) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < items.length; i++)
Padding(
padding: EdgeInsets.only(bottom: base * 0.25),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: base * 1.5,
child: Text(
ordered ? '${start + i}.' : '\u2022',
style: foreground,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in items[i].blocks) _block(context, child, base),
],
),
),
],
),
),
],
),
),
DefinitionListBlock(:final items) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final item in items) ...[
Text.rich(
TextSpan(children: _inline(item.term)),
style: foreground.copyWith(fontWeight: FontWeight.bold),
),
Padding(
padding: EdgeInsetsDirectional.only(start: base * 1.5, bottom: base * 0.5),
child: Text.rich(TextSpan(children: _inline(item.definition)), style: foreground),
),
],
],
),
),
TableBlock(:final rows, :final caption) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (caption != null)
Text(caption,
style: foreground.copyWith(fontWeight: FontWeight.bold)),
Table(
border: TableBorder.all(color: palette.border),
defaultVerticalAlignment: TableCellVerticalAlignment.top,
children: [
for (final row in rows)
TableRow(
children: [
for (final cell in row.cells)
TableCell(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: base * 0.6,
vertical: base * 0.3,
),
child: Text.rich(
TextSpan(children: _inline(cell.inline)),
style: foreground.copyWith(
fontWeight: cell.header ? FontWeight.bold : null,
),
),
),
),
],
),
],
),
],
),
),
ImageBlock(:final src, :final alt, :final allowed) => Padding(
padding: EdgeInsets.only(bottom: base),
child: allowed
? Image.network(
src,
fit: BoxFit.scaleDown,
errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true),
)
: _imageFallback(alt, base),
),
FormBlock() => Padding(
padding: EdgeInsets.only(bottom: base),
child: _MewsForm(
form: block,
foreground: foreground,
onFormSubmit: widget.onFormSubmit,
ownPage: widget.ownPage,
),
),
};
}
Widget _imageFallback(String alt, double base, {bool unavailable = false}) {
final palette = widget.palette;
final strings = StringsScope.of(context);
final messageId = unavailable ? 'error-image-unavailable' : 'error-image-blocked';
final message = alt.isEmpty ? strings.t(messageId) : '${strings.t(messageId)} $alt';
return Container(
width: double.infinity,
padding: EdgeInsets.all(base * 0.75),
decoration: BoxDecoration(
border: Border.all(color: palette.border),
color: palette.preBackground,
),
child: Text(
message,
style: TextStyle(color: palette.foreground, fontSize: base * 0.9),
),
);
}
/// Builds the span tree. [linkRecognizer] carries the enclosing link's
/// recognizer down to every glyph-owning span: hit-testing resolves to leaf
/// spans only, so a recognizer on a parent span alone never fires.
List<InlineSpan> _inline(List<MewsInline> inline, [TapGestureRecognizer? linkRecognizer]) {
final palette = widget.palette;
return [
for (final node in inline)
switch (node) {
MewsText(:final text) => TextSpan(text: text, recognizer: linkRecognizer),
MewsLineBreak() => const TextSpan(text: '\n'),
MewsEmphasis(:final style, :final children) => TextSpan(
style: switch (style) {
EmphasisStyle.strong => const TextStyle(fontWeight: FontWeight.bold),
EmphasisStyle.emphasis when widget.ownPage => TextStyle(
color: palette.foreground.withValues(alpha: 0.65),
fontSize: 15.0 * widget.fontScale,
),
EmphasisStyle.emphasis => const TextStyle(fontStyle: FontStyle.italic),
EmphasisStyle.term => const TextStyle(fontStyle: FontStyle.italic),
EmphasisStyle.quote => const TextStyle(fontStyle: FontStyle.italic),
_ => const TextStyle(fontFamily: 'monospace'),
},
children: _inline(children, linkRecognizer)),
MewsLink(:final href, :final children) => TextSpan(
style: TextStyle(
color: widget.visitedUrls.contains(href) ? palette.visited : palette.link,
// Underlines mark links in prose; on our own pages they are
// controls, so weight carries them instead.
decoration:
widget.ownPage ? TextDecoration.none : TextDecoration.underline,
fontWeight: widget.ownPage ? FontWeight.w500 : null,
),
recognizer: _recognizer(node),
children: _inline(children, _recognizer(node))),
},
];
}
TapGestureRecognizer _recognizer(MewsLink link) {
return _recognizers.putIfAbsent(link, () {
return TapGestureRecognizer()..onTap = () => widget.onLinkTap(link.href);
});
}
}
/// A form rendered from the page model. Submitting sends the field values to
/// the browser controller; reset restores the author's initial values.
class _MewsForm extends StatefulWidget {
const _MewsForm({
required this.form,
required this.foreground,
required this.onFormSubmit,
this.ownPage = false,
});
final FormBlock form;
final TextStyle foreground;
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
/// On our own pages a form is a search box or a one-field prompt, so it sits
/// on one line instead of filling the column like a page's own form.
final bool ownPage;
@override
State<_MewsForm> createState() => _MewsFormState();
}
class _MewsFormState extends State<_MewsForm> {
final _controllers = <String, TextEditingController>{};
final _values = <String, String>{};
@override
void dispose() {
for (final controller in _controllers.values) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final strings = StringsScope.of(context);
final form = widget.form;
final style = widget.foreground;
Widget button(MewsSubmitField field) => FilledButton(
onPressed: () => field.reset ? _reset(form) : _submit(form),
child: Text(
field.label ??
(field.reset
? strings.t('form-reset-default')
: strings.t('form-submit-default')),
style: TextStyle(fontFamily: style.fontFamily),
),
);
if (widget.ownPage) {
final inputs = form.fields.where((f) => f is! MewsSubmitField).toList();
return Form(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (final field in inputs)
if (field is! MewsHiddenField)
Expanded(child: _field(field, style))
else
_field(field, style),
for (final field in form.fields.whereType<MewsSubmitField>())
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: button(field),
),
],
),
);
}
return Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final field in form.fields) _field(field, style),
for (final field in form.fields.whereType<MewsSubmitField>())
Padding(
padding: const EdgeInsets.only(top: 8),
child: button(field),
),
],
),
);
}
Widget _field(MewsFormField field, TextStyle style) {
Widget control = switch (field) {
MewsTextField(:final name, :final password, :final value, :final maxLength) => TextFormField(
controller: _controllers.putIfAbsent(
name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))),
obscureText: password,
maxLength: maxLength,
style: style,
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsTextAreaField(:final name, :final value, :final rows) => TextFormField(
controller: _controllers.putIfAbsent(
name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))),
minLines: rows,
maxLines: rows,
style: style,
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsCheckboxField(:final name, :final checked) => CheckboxListTile(
title: field.label == null ? null : Text(field.label!, style: style),
value: _values.putIfAbsent(name, () => checked ? 'on' : '') == 'on',
onChanged: (value) => setState(() => _values[name] = value == true ? 'on' : ''),
),
MewsRadioField(:final name, :final value, :final checked) => RadioGroup<String>(
groupValue: _values.putIfAbsent(name, () => checked ? value : ''),
onChanged: (value) => setState(() => _values[name] = value ?? ''),
child: RadioListTile<String>(
value: value,
title: field.label == null ? null : Text(field.label!, style: style),
),
),
MewsSelectField(:final name, :final options) => options.isEmpty
? const SizedBox.shrink()
: DropdownButtonFormField<String>(
initialValue: _values.putIfAbsent(
name,
() => options.firstWhere((o) => o.selected, orElse: () => options.first).value),
items: [
for (final option in options) DropdownMenuItem(value: option.value, child: Text(option.value)),
],
onChanged: (value) => _values[name] = value ?? '',
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsSubmitField() => const SizedBox.shrink(),
MewsHiddenField() => const SizedBox.shrink(),
};
return control;
}
void _submit(FormBlock form) {
final values = <String, String>{};
for (final field in form.fields) {
switch (field) {
case MewsTextField(:final name):
values[name] = _controllers[name]?.text ?? '';
case MewsTextAreaField(:final name):
values[name] = _controllers[name]?.text ?? '';
case MewsCheckboxField(:final name, :final value):
if (_values[name] == 'on') values[name] = value;
case MewsRadioField(:final name, :final value):
if (_values[name] == value) values[name] = value;
case MewsSelectField(:final name):
values[name] = _values[name] ?? '';
case MewsHiddenField(:final name, :final value):
values[name] = value;
case MewsSubmitField():
break;
}
}
widget.onFormSubmit(form, values);
}
void _reset(FormBlock form) {
setState(() {
_values.clear();
for (final controller in _controllers.values) {
controller.clear();
}
// Restore the author's initial text field values.
for (final field in form.fields) {
switch (field) {
case MewsTextField(:final name, :final value):
_controllers[name]?.text = value;
case MewsTextAreaField(:final name, :final value):
_controllers[name]?.text = value;
default:
break;
}
}
});
}
}