Files
Client/lib/widget/prosemirror/pm_table_view.dart
T

326 lines
10 KiB
Dart

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> 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<PmTableView> createState() => _PmTableViewState();
}
class _PmTableViewState extends State<PmTableView> {
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<PmTableRow>().toList();
if (rows.isEmpty) return const SizedBox.shrink();
final occupied = <int, Set<int>>{};
final placements = <Widget>[];
var columnCount = 0;
for (var r = 0; r < rows.length; r++) {
var col = 0;
for (final cell in rows[r].children.whereType<PmTableCell>()) {
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] ??= <int>{};
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<PmTableMode>(
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<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 (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,
),
),
),
);
}
}