482 lines
17 KiB
Dart
482 lines
17 KiB
Dart
import 'dart:ui' as ui show TextDirection;
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../l10n/strings.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,
|
|
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;
|
|
|
|
@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,
|
|
child: ColoredBox(
|
|
color: palette.background,
|
|
child: ListView(
|
|
controller: widget.scrollController,
|
|
padding: EdgeInsets.all(base),
|
|
children: [
|
|
Center(
|
|
child: ConstrainedBox(
|
|
// max-width: 40em
|
|
constraints: BoxConstraints(maxWidth: 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), fontSize: base * 0.85),
|
|
),
|
|
);
|
|
|
|
// Blocks -------------------------------------------------------------------
|
|
|
|
Widget _block(BuildContext context, MewsBlock block, double base) {
|
|
final palette = widget.palette;
|
|
final foreground = TextStyle(color: palette.foreground, fontSize: base, height: 1.5);
|
|
|
|
return switch (block) {
|
|
HeadingBlock(:final level, :final inline) => Padding(
|
|
padding: EdgeInsets.only(
|
|
top: level == 1 ? base * 0.5 : 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],
|
|
),
|
|
),
|
|
),
|
|
ParagraphBlock(:final inline) => Padding(
|
|
padding: EdgeInsets.only(bottom: 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: base * 2),
|
|
child: Divider(color: palette.border, height: 1),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
};
|
|
}
|
|
|
|
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 => 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,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
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,
|
|
});
|
|
|
|
final FormBlock form;
|
|
final TextStyle foreground;
|
|
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
|
|
|
|
@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;
|
|
|
|
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: FilledButton(
|
|
onPressed: () => field.reset ? _reset(form) : _submit(form),
|
|
child: Text(
|
|
field.label ??
|
|
(field.reset ? strings.t('form-reset-default') : strings.t('form-submit-default')),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|