feat: offer a non-Mews web page as a simplified view or in the browser

This commit is contained in:
randogoth 2026-10-12 11:13:34 +03:00
parent cea66fbb42
commit 00e42c925c
24 changed files with 863 additions and 152 deletions

View file

@ -15,6 +15,7 @@ import 'package:flutter/material.dart';
import '../../l10n/strings.dart';
import '../../application/fonts.dart';
import '../../mews/document.dart';
import '../../mews/html.dart' show htmlSpecVersion;
import 'palettes.dart';
/// How long the reader has to stop typing before a search box narrows its
@ -84,6 +85,11 @@ class _MewsPageViewState extends State<MewsPageView> {
int _focusedLink = -1;
int _linkCount = 0;
int _linkCounter = 0;
/// Forms of the page, counted in reading order as it is built. A web page
/// can carry the same form twice — a search box in both a desktop and a
/// mobile header — so the action alone does not identify one.
int _formCounter = 0;
final _linkHrefs = <String>[];
final _linkSpans = <TextSpan>[];
final _region = FocusNode();
@ -157,8 +163,13 @@ class _MewsPageViewState extends State<MewsPageView> {
_linkHrefs.clear();
_linkSpans.clear();
_linkCounter = 0;
_formCounter = 0;
final blocks = [
if (!widget.page.isMews)
// A converted web page says so: what it shows is only the part of it
// the Mews subset reaches.
if (widget.page.specVersion == htmlSpecVersion)
_notice(strings.t('error-simplified-view'), base, palette)
else if (!widget.page.isMews)
_notice(strings.t('error-non-mews'), base, palette),
for (final block in widget.page.body) _block(context, block, base),
];
@ -242,6 +253,72 @@ class _MewsPageViewState extends State<MewsPageView> {
);
}
/// A table, every row padded out to the width of the widest. Flutter's
/// Table requires each row to hold the same number of cells, and a web
/// page's rows are routinely ragged. A colspan is not honored, so a cell
/// that spans several columns simply sits in the first of them.
Widget _table(
List<MewsTableRow> rows,
String? caption,
double base,
TextStyle foreground,
) {
final palette = widget.palette;
var width = 0;
for (final row in rows) {
if (row.cells.length > width) width = row.cells.length;
}
// Nothing to draw, and a Table needs at least one column.
if (width == 0) return const SizedBox.shrink();
return 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 (var i = 0; i < width; i++)
TableCell(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: base * 0.6,
vertical: base * 0.3,
),
child: Text.rich(
TextSpan(
children: i < row.cells.length
? _inline(row.cells[i].inline)
: const <InlineSpan>[],
),
style: foreground.copyWith(
fontWeight:
i < row.cells.length && row.cells[i].header
? FontWeight.bold
: null,
),
),
),
),
],
),
],
),
],
),
);
}
Widget _notice(String text, double base, MewsPalette palette) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text(
@ -417,57 +494,19 @@ class _MewsPageViewState extends State<MewsPageView> {
],
),
),
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,
),
),
),
),
],
),
],
),
],
),
),
TableBlock(:final rows, :final caption) =>
_table(rows, caption, base, foreground),
ImageBlock(:final src, :final alt, :final allowed) => Padding(
padding: EdgeInsets.only(bottom: base),
child: allowed ? _image(src, alt, base) : _imageFallback(alt, base),
),
FormBlock() => Padding(
padding: EdgeInsets.only(bottom: base),
// Keyed by what it submits to: a page rebuilt as the reader types in
// this box is matched up with the box they are typing in, so the text
// and the cursor stay where they are.
key: ValueKey('form:${block.action}'),
// Keyed by where it sits and what it submits to: a page rebuilt as
// the reader types in this box is matched up with the box they are
// typing in, so the text and the cursor stay where they are. The
// position keeps two forms with one action apart.
key: ValueKey('form:${_formCounter++}:${block.action}'),
child: _MewsForm(
form: block,
foreground: foreground,