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

142 lines
4.3 KiB
Dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'pm_node.dart';
import 'pm_render_scope.dart';
class PmImageView extends StatelessWidget {
final PmImage node;
const PmImageView({required this.node, super.key});
/// Full-resolution source, used for the zoomable fullscreen view.
static ImageProvider? sourceProvider(PmImage node) {
final bytes = node.bytes;
if (bytes != null) return MemoryImage(bytes);
if (node.src.startsWith('http')) {
return CachedNetworkImageProvider(node.src);
}
return null;
}
/// Inline images never render wider than the screen, so they are decoded at
/// most at its physical width instead of the (often camera-sized) original.
/// [PmJsonView] precaches through this too, so both hit the same cache key.
static ImageProvider? inlineProvider(BuildContext context, PmImage node) {
final source = sourceProvider(node);
if (source == null) return null;
final width =
(MediaQuery.sizeOf(context).width *
MediaQuery.devicePixelRatioOf(context))
.round();
return ResizeImage(source, width: width);
}
@override
Widget build(BuildContext context) {
final provider = sourceProvider(node);
final inline = inlineProvider(context, node);
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
final targetWidth = _resolveWidth(node.width, available);
Widget image = ConstrainedBox(
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
child: inline == null
? _brokenImage(context)
: Image(
image: inline,
errorBuilder: (context, error, stack) =>
_brokenImage(context),
),
);
final href = node.href;
if (href != null && href.isNotEmpty) {
image = InkWell(
onTap: () => PmRenderScope.maybeOf(context)?.onLinkTap?.call(href),
child: image,
);
} else if (provider != null) {
image = InkWell(
onTap: () => _openFullscreen(context, provider),
child: image,
);
}
return Align(alignment: _alignment(node.align), child: image);
},
);
}
Widget _brokenImage(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Icon(
Icons.broken_image_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
void _openFullscreen(BuildContext context, ImageProvider provider) {
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (context) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
),
body: PhotoView(
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3,
imageProvider: provider,
backgroundDecoration: const BoxDecoration(color: Colors.black),
),
),
);
}
Alignment _alignment(String? align) {
switch (align) {
case 'center':
return Alignment.center;
case 'right':
return Alignment.centerRight;
default:
return Alignment.centerLeft;
}
}
double? _resolveWidth(String? width, double available) {
if (width == null) return null;
final match = RegExp(r'^(\d+(?:\.\d+)?)(px|%|em|rem)$').firstMatch(width);
if (match == null) return null;
final value = double.parse(match.group(1)!);
final double resolved;
switch (match.group(2)) {
case '%':
resolved = available * value / 100;
case 'em':
case 'rem':
resolved = value * 16;
default:
resolved = value;
}
return resolved.clamp(1, available);
}
}