import 'dart:math'; import 'package:flutter/material.dart'; 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 = 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. /// /// 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; const PmTableView({required this.node, super.key}); @override State createState() => _PmTableViewState(); } class _PmTableViewState extends State { final ScrollController _scroll = ScrollController(); bool _showLeftShadow = false; bool _showRightShadow = false; /// Whether the table is too wide to fit and therefore needs the mode toggle. /// Measured from the natural (scroll) layout's overflow — see [_updateShadows]. bool _overflows = false; @override void dispose() { _scroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final rows = widget.node.children.whereType().toList(); if (rows.isEmpty) return const SizedBox.shrink(); final occupied = >{}; final placements = []; var columnCount = 0; for (var r = 0; r < rows.length; r++) { var col = 0; for (final cell in rows[r].children.whereType()) { while (occupied[r]?.contains(col) ?? false) { col++; } placements.add( GridPlacement( columnStart: col, columnSpan: cell.colspan, rowStart: r, rowSpan: cell.rowspan, child: _cell(context, cell, r), ), ); for (var dr = 0; dr < cell.rowspan; dr++) { final set = occupied[r + dr] ??= {}; for (var dc = 0; dc < cell.colspan; dc++) { set.add(col + dc); } } col += cell.colspan; columnCount = max(columnCount, col); } } if (columnCount == 0) return const SizedBox.shrink(); return ValueListenableBuilder( valueListenable: pmTableMode, builder: (context, mode, _) { // When the table already fits, the mode is moot: render the natural // grid (which shows identically and won't scroll) and hide the toggle, // exactly like the web ticker. The scroll layout keeps measuring the // overflow so the toggle reappears if the width or content changes. final effectiveMode = _overflows ? mode : PmTableMode.scroll; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (_overflows) ...[ _Toolbar(mode: mode), const SizedBox(height: AppSpacing.xs), ], _body(context, effectiveMode, rows.length, columnCount, placements), ], ); }, ); } Widget _body( BuildContext context, PmTableMode mode, int rowCount, int columnCount, List 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( 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 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 (canScroll != _overflows || showLeft != _showLeftShadow || showRight != _showRightShadow) { setState(() { _overflows = canScroll; _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: background, border: Border.all(color: theme.colorScheme.outlineVariant), ), // 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]. Only shown for tables /// wide enough to need it; 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, ), ), ), ); } }