implemented Ticker page selection persistence and enhanced ProseMirror table display modes
This commit is contained in:
@@ -20,3 +20,24 @@ class PmRenderScope extends InheritedWidget {
|
||||
bool updateShouldNotify(PmRenderScope oldWidget) =>
|
||||
oldWidget.onLinkTap != onLinkTap;
|
||||
}
|
||||
|
||||
/// Controls whether inline text below it may wrap. Table cells disable wrapping
|
||||
/// in the scroll/zoom modes so every row stays a single line (bound height),
|
||||
/// matching the web ticker; the default outside tables is to wrap.
|
||||
class PmCellTextFlow extends InheritedWidget {
|
||||
final bool softWrap;
|
||||
|
||||
const PmCellTextFlow({
|
||||
required this.softWrap,
|
||||
required super.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
static bool softWrapOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<PmCellTextFlow>()?.softWrap ??
|
||||
true;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(PmCellTextFlow oldWidget) =>
|
||||
oldWidget.softWrap != softWrap;
|
||||
}
|
||||
|
||||
@@ -172,8 +172,15 @@ class _PmRichTextState extends State<PmRichText> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Text.rich(_span, textAlign: widget.textAlign);
|
||||
Widget build(BuildContext context) {
|
||||
final softWrap = PmCellTextFlow.softWrapOf(context);
|
||||
return Text.rich(
|
||||
_span,
|
||||
textAlign: widget.textAlign,
|
||||
softWrap: softWrap,
|
||||
overflow: softWrap ? TextOverflow.clip : TextOverflow.visible,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a CSS `#rgb`/`#rrggbb`/`#rrggbbaa` hex or a small set of named colors.
|
||||
|
||||
@@ -6,20 +6,48 @@ import 'package:flutter_layout_grid/flutter_layout_grid.dart';
|
||||
import '../../theming/app_theme.dart';
|
||||
import 'pm_document_view.dart';
|
||||
import 'pm_node.dart';
|
||||
import 'pm_render_scope.dart';
|
||||
|
||||
/// Table display mode, mirroring the web ticker's three-way toggle. Switching
|
||||
/// one table switches all of them at once — the choice is shared, exactly like
|
||||
/// the web view (which persists it in `localStorage`; here it lives for the
|
||||
/// duration of the app session).
|
||||
enum PmTableMode { scroll, wrap, zoom }
|
||||
|
||||
final ValueNotifier<PmTableMode> pmTableMode = ValueNotifier(PmTableMode.scroll);
|
||||
|
||||
/// Renders a ProseMirror table. Flutter's built-in `Table` cannot span cells,
|
||||
/// so `flutter_layout_grid` places each cell explicitly, honouring
|
||||
/// colspan/rowspan via a simple HTML-style occupancy scan.
|
||||
class PmTableView extends StatelessWidget {
|
||||
///
|
||||
/// Three display modes match the web ticker:
|
||||
/// - `scroll`: content-sized columns, single-line rows, horizontal scroll with
|
||||
/// edge shadows that hint the overflow.
|
||||
/// - `wrap`: columns share the width and text wraps — no scrolling.
|
||||
/// - `zoom`: the full table is scaled down to fit on screen at a glance.
|
||||
class PmTableView extends StatefulWidget {
|
||||
final PmTable node;
|
||||
|
||||
static const double _minColumnWidth = 140;
|
||||
|
||||
const PmTableView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
State<PmTableView> createState() => _PmTableViewState();
|
||||
}
|
||||
|
||||
class _PmTableViewState extends State<PmTableView> {
|
||||
final ScrollController _scroll = ScrollController();
|
||||
bool _showLeftShadow = false;
|
||||
bool _showRightShadow = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = node.children.whereType<PmTableRow>().toList();
|
||||
final rows = widget.node.children.whereType<PmTableRow>().toList();
|
||||
if (rows.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final occupied = <int, Set<int>>{};
|
||||
@@ -38,7 +66,7 @@ class PmTableView extends StatelessWidget {
|
||||
columnSpan: cell.colspan,
|
||||
rowStart: r,
|
||||
rowSpan: cell.rowspan,
|
||||
child: _cell(context, cell),
|
||||
child: _cell(context, cell, r),
|
||||
),
|
||||
);
|
||||
for (var dr = 0; dr < cell.rowspan; dr++) {
|
||||
@@ -53,42 +81,228 @@ class PmTableView extends StatelessWidget {
|
||||
}
|
||||
if (columnCount == 0) return const SizedBox.shrink();
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final available = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: _minColumnWidth * columnCount;
|
||||
final columnWidth = max(_minColumnWidth, available / columnCount);
|
||||
final totalWidth = columnWidth * columnCount;
|
||||
|
||||
final grid = SizedBox(
|
||||
width: totalWidth,
|
||||
child: LayoutGrid(
|
||||
columnSizes: List.filled(columnCount, fixed(columnWidth)),
|
||||
rowSizes: List.filled(rows.length, auto),
|
||||
children: placements,
|
||||
),
|
||||
);
|
||||
|
||||
if (totalWidth <= available) return grid;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: grid,
|
||||
);
|
||||
},
|
||||
return ValueListenableBuilder<PmTableMode>(
|
||||
valueListenable: pmTableMode,
|
||||
builder: (context, mode, _) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Toolbar(mode: mode),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
_body(context, mode, rows.length, columnCount, placements),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cell(BuildContext context, PmTableCell cell) {
|
||||
Widget _body(
|
||||
BuildContext context,
|
||||
PmTableMode mode,
|
||||
int rowCount,
|
||||
int columnCount,
|
||||
List<Widget> placements,
|
||||
) {
|
||||
switch (mode) {
|
||||
case PmTableMode.wrap:
|
||||
return PmCellTextFlow(
|
||||
softWrap: true,
|
||||
child: LayoutGrid(
|
||||
columnSizes: List.filled(columnCount, flex(1)),
|
||||
rowSizes: List.filled(rowCount, auto),
|
||||
children: placements,
|
||||
),
|
||||
);
|
||||
case PmTableMode.zoom:
|
||||
final grid = _naturalGrid(rowCount, columnCount, placements);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.topLeft,
|
||||
child: grid,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case PmTableMode.scroll:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateShadows());
|
||||
return Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: (_) {
|
||||
_updateShadows();
|
||||
return false;
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: _naturalGrid(rowCount, columnCount, placements),
|
||||
),
|
||||
),
|
||||
_edgeShadow(context, left: true, visible: _showLeftShadow),
|
||||
_edgeShadow(context, left: false, visible: _showRightShadow),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-sized, single-line grid shared by the scroll and zoom modes.
|
||||
Widget _naturalGrid(int rowCount, int columnCount, List<Widget> placements) =>
|
||||
PmCellTextFlow(
|
||||
softWrap: false,
|
||||
child: LayoutGrid(
|
||||
columnSizes: List.filled(columnCount, auto),
|
||||
rowSizes: List.filled(rowCount, auto),
|
||||
children: placements,
|
||||
),
|
||||
);
|
||||
|
||||
void _updateShadows() {
|
||||
if (!_scroll.hasClients) return;
|
||||
final pos = _scroll.position;
|
||||
final canScroll = pos.maxScrollExtent > 0.5;
|
||||
final showLeft = canScroll && pos.pixels > 0.5;
|
||||
final showRight = canScroll && pos.pixels < pos.maxScrollExtent - 0.5;
|
||||
if (showLeft != _showLeftShadow || showRight != _showRightShadow) {
|
||||
setState(() {
|
||||
_showLeftShadow = showLeft;
|
||||
_showRightShadow = showRight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _edgeShadow(
|
||||
BuildContext context, {
|
||||
required bool left,
|
||||
required bool visible,
|
||||
}) {
|
||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||
final color = Colors.black.withValues(alpha: dark ? 0.28 : 0.12);
|
||||
return Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: left ? 0 : null,
|
||||
right: left ? null : 0,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: Container(
|
||||
width: 22,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: left ? Alignment.centerLeft : Alignment.centerRight,
|
||||
end: left ? Alignment.centerRight : Alignment.centerLeft,
|
||||
colors: [color, color.withValues(alpha: 0)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cell(BuildContext context, PmTableCell cell, int rowIndex) {
|
||||
final theme = Theme.of(context);
|
||||
Color? background;
|
||||
if (cell.header) {
|
||||
background = theme.colorScheme.surfaceContainerHighest;
|
||||
} else if (rowIndex.isOdd) {
|
||||
background = theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.4,
|
||||
);
|
||||
}
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cell.header ? theme.colorScheme.surfaceContainerHighest : null,
|
||||
color: background,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: pmBlocks(cell.children, gap: AppSpacing.xs),
|
||||
// The grid lays cells out with loose constraints, so a shorter cell would
|
||||
// shrink below its row's height (set by the tallest cell) and its border
|
||||
// would not line up. Align stretches the box to fill the whole cell area
|
||||
// while still reporting the content height for the row's intrinsic size.
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: pmBlocks(cell.children, gap: AppSpacing.xs),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact segmented control that toggles [pmTableMode]. Always visible so the
|
||||
/// modes are discoverable; a tap switches every table on screen at once.
|
||||
class _Toolbar extends StatelessWidget {
|
||||
final PmTableMode mode;
|
||||
|
||||
const _Toolbar({required this.mode});
|
||||
|
||||
static const List<(PmTableMode, IconData, String)> _modes = [
|
||||
(PmTableMode.scroll, Icons.swap_horiz, 'Originalbreite (seitlich scrollen)'),
|
||||
(PmTableMode.wrap, Icons.wrap_text, 'Spalten umbrechen'),
|
||||
(PmTableMode.zoom, Icons.fit_screen, 'Verkleinern (alles auf einen Blick)'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < _modes.length; i++) ...[
|
||||
if (i > 0)
|
||||
Container(
|
||||
width: 1,
|
||||
height: 26,
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
_button(context, _modes[i].$1, _modes[i].$2, _modes[i].$3),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _button(
|
||||
BuildContext context,
|
||||
PmTableMode target,
|
||||
IconData icon,
|
||||
String tooltip,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final active = target == mode;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: () => pmTableMode.value = target,
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
color: active ? theme.colorScheme.primaryContainer : Colors.transparent,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: active
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user