Files
clide/lib/widgets/src/clide_lightbox.dart
T
jpmschweitzerandClaude Opus 4.8 c08f249b00 docs: fix 41 unresolved dartdoc references
Convert non-resolving [refs] in doc comments to backtick code-spans across 24
lib/ files (param/field names out of doc scope, method refs on other classes,
non-API strings like regex char-classes and command ids). Verified 0
"unresolved doc reference" warnings via `dart doc --validate-links`. The
dart-doc CI gate (test.yml) never ran before — Gitea Actions was inactive — so
this debt had accumulated unchecked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:09:55 +02:00

201 lines
7.0 KiB
Dart

/// Full-screen zoom + pan overlay (T-252 / D-78). A reusable primitive: it
/// takes any `child` and shows it over the [DialogRouter]'s dimmed backdrop
/// (the host supplies the backdrop + outside-click dismiss). The image card is
/// its first consumer; canvas / graph / diff previews can adopt it later.
///
/// Open: content fits the viewport. Scroll wheel / pinch zooms; drag pans when
/// zoomed in; double-click resets to fit; Esc (or the close button, or a
/// backdrop click) dismisses. Min/max scale clamp. Own-the-rendering-stack:
/// Flutter's [InteractiveViewer] is the SDK-first base, with the zoom gestures
/// kept here so the UX is ours.
library;
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/icons/phosphor.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class ClideLightbox extends StatefulWidget {
const ClideLightbox({super.key, required this.child, required this.onDismiss, this.minScale = 0.5, this.maxScale = 8.0});
/// The content to zoom — constrained to the viewport on open (pass an image
/// with `fit: BoxFit.contain` so it fits, then scales on zoom).
final Widget child;
final VoidCallback onDismiss;
final double minScale;
final double maxScale;
@override
State<ClideLightbox> createState() => _ClideLightboxState();
}
class _ClideLightboxState extends State<ClideLightbox> {
final TransformationController _tc = TransformationController();
final FocusNode _focus = FocusNode(debugLabel: 'ClideLightbox');
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focus.requestFocus();
});
}
@override
void dispose() {
_tc.dispose();
_focus.dispose();
super.dispose();
}
void _reset() => _tc.value = Matrix4.identity();
/// A single tap that lands on the dimmed canvas around the image dismisses,
/// matching Esc / the close button (T-309). A tap on the actual image pixels
/// (not the transparent letterbox that fills the viewer) does not — nor does a
/// drag/zoom, which the [InteractiveViewer] consumes so no tap fires.
void _onTapUp(TapUpDetails d) {
final rect = _imageRect();
if (rect != null && rect.contains(d.globalPosition)) return;
widget.onDismiss();
}
/// On-screen rect of the painted image (fit:contain, after any zoom/pan), or
/// null if there's no image to find (a non-image child → any tap dismisses).
Rect? _imageRect() {
final root = context.findRenderObject();
if (root == null) return null;
RenderImage? image;
void find(RenderObject o) {
if (image != null) return;
if (o is RenderImage) {
image = o;
return;
}
o.visitChildren(find);
}
root.visitChildren(find);
final ri = image;
final pixels = ri?.image;
if (ri == null || pixels == null) return null;
final natural = Size(pixels.width.toDouble(), pixels.height.toDouble());
final painted = applyBoxFit(BoxFit.contain, natural, ri.size).destination;
final local = Alignment.center.inscribe(painted, Offset.zero & ri.size);
return MatrixUtils.transformRect(ri.getTransformTo(null), local);
}
void _onScroll(PointerSignalEvent e) {
if (e is! PointerScrollEvent) return;
final current = _tc.value.getMaxScaleOnAxis();
final factor = e.scrollDelta.dy < 0 ? 1.12 : 1 / 1.12;
final applied = (current * factor).clamp(widget.minScale, widget.maxScale) / current;
if (applied == 1.0) return;
final box = context.findRenderObject() as RenderBox?;
final p = box == null ? Offset.zero : box.globalToLocal(e.position);
_tc.value = _tc.value.clone()
..translateByDouble(p.dx, p.dy, 0, 1)
..scaleByDouble(applied, applied, applied, 1)
..translateByDouble(-p.dx, -p.dy, 0, 1);
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final size = MediaQuery.of(context).size;
return Focus(
focusNode: _focus,
onKeyEvent: _onKey,
child: SizedBox(
// Leave a margin so the host backdrop is clickable to dismiss.
width: size.width * 0.94,
height: size.height * 0.94,
child: Stack(
children: [
Positioned.fill(
child: Listener(
onPointerSignal: _onScroll,
child: GestureDetector(
onDoubleTap: _reset,
onTapUp: _onTapUp,
child: InteractiveViewer(
transformationController: _tc,
minScale: widget.minScale,
maxScale: widget.maxScale,
boundaryMargin: const EdgeInsets.all(double.infinity),
child: widget.child,
),
),
),
),
Positioned(
top: 8,
right: 8,
child: _IconChip(icon: PhosphorIcons.byName('x'), label: 'close', onTap: widget.onDismiss, tokens: tokens),
),
Positioned(
bottom: 8,
left: 0,
right: 0,
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(color: tokens.panelHeader, borderRadius: BorderRadius.circular(4)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: ClideText('scroll to zoom · double-click to reset · Esc to close', fontSize: clideFontMeta, color: tokens.globalTextMuted),
),
),
),
),
],
),
),
);
}
}
class _IconChip extends StatelessWidget {
const _IconChip({required this.icon, required this.label, required this.onTap, required this.tokens});
final ClideIconPainter icon;
final String label;
final VoidCallback onTap;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: tokens.panelHeader,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: tokens.panelBorder),
),
child: ClideIcon(icon, size: 16, color: tokens.globalForeground),
),
),
),
);
}
}