add ClideLightbox — click image cards to enlarge (T-252)
The inline image cards (T-249) are often too small to read. Clicking one now opens a full-screen lightbox: zoom (scroll wheel / pinch), pan when zoomed, double-click to reset to fit, Esc / close button / backdrop click to dismiss. ClideLightbox is a reusable primitive (lib/widgets/) over Flutter's InteractiveViewer with clide-owned zoom gestures, shown via the DialogRouter (dimmed backdrop, single modal at a time, D-78). The card stays display-only; the click is a navigation gesture, not an inline control. CLI parity (D-6): `clide image show <path> --fullscreen` opens straight into the lightbox instead of injecting a card. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
@@ -234,7 +235,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
),
|
||||
AssistantToolUse() => _toolUse(i),
|
||||
ToolResultMessage() => _toolResult(i),
|
||||
ImageMessage() => _image(i),
|
||||
ImageMessage() => _image(context, i),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,7 +244,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
/// Bounded so a large image scales down to the pane width and never pushes
|
||||
/// past a readable height; a missing/unreadable file degrades to a muted
|
||||
/// placeholder rather than throwing.
|
||||
Widget _image(ImageMessage m) {
|
||||
Widget _image(BuildContext context, ImageMessage m) {
|
||||
final caption = m.caption;
|
||||
return ConversationCard(
|
||||
accent: tokens.globalTextMuted,
|
||||
@@ -252,15 +253,24 @@ class _ConversationTurn extends StatelessWidget {
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
child: Image.file(
|
||||
File(m.path),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
|
||||
// The card stays display-only (D-78); the click is a navigation
|
||||
// gesture that opens the full-screen lightbox (T-252), not an inline
|
||||
// control.
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => _openLightbox(context, m.path),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
child: Image.file(
|
||||
File(m.path),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -273,6 +283,19 @@ class _ConversationTurn extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _openLightbox(BuildContext context, String path) {
|
||||
ClideKernel.of(context).dialog.show<Object>(
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imagePlaceholder(String path) => Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -336,6 +336,17 @@ class ClaudeExtension extends ClideExtension {
|
||||
void _onImageShow(Message m) {
|
||||
final path = m.data['path'] as String?;
|
||||
if (path == null || path.isEmpty) return;
|
||||
// `clide image show <path> --fullscreen` (T-252): open straight into the
|
||||
// lightbox instead of injecting an inline card.
|
||||
if (m.data['fullscreen'] == true) {
|
||||
_ctx?.dialog.show<Object>(
|
||||
(c, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image.file(File(path), fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
|
||||
if (target == null) return;
|
||||
target.conversation.inject(ImageMessage(
|
||||
|
||||
@@ -48,6 +48,7 @@ void registerImageCommands(
|
||||
args: {
|
||||
'path': ArgSpec(required: true, rejectLeadingDash: true),
|
||||
'caption': ArgSpec(),
|
||||
'fullscreen': ArgSpec(type: ArgType.boolean),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -107,11 +108,13 @@ Future<IpcResponse> _show(
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
|
||||
);
|
||||
}
|
||||
final fullscreen = req.args['fullscreen'] == true;
|
||||
publish('cli', imageShowChannel, {
|
||||
'path': resolved,
|
||||
if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(),
|
||||
if (fullscreen) 'fullscreen': true,
|
||||
});
|
||||
return IpcResponse.ok(id: req.id, data: {'path': resolved, if (caption != null) 'caption': caption, 'shown': true});
|
||||
return IpcResponse.ok(id: req.id, data: {'path': resolved, if (caption != null) 'caption': caption, 'fullscreen': fullscreen, 'shown': true});
|
||||
}
|
||||
|
||||
/// Lower-cased extension (without the dot) of [path], or '' if none.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/// 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/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();
|
||||
|
||||
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,
|
||||
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.xMark, 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export 'src/clide_column_hat.dart';
|
||||
export 'src/clide_code_block.dart';
|
||||
export 'src/clide_divider.dart';
|
||||
export 'src/clide_filter_box.dart';
|
||||
export 'src/clide_lightbox.dart';
|
||||
export 'src/clide_markdown.dart';
|
||||
export 'src/clide_marquee.dart';
|
||||
export 'src/clide_svg_view.dart';
|
||||
|
||||
Reference in New Issue
Block a user