Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions lib/providers/image_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -340,11 +340,16 @@ class PlacedImageProvider extends Notifier<ImageState> {
return images.map(_migrateLoadedImage).toList();
}

void updateScale(int index, double scale) {
/// Sets an image's scale and position together: on defense a resize moves
/// the stored position so the image's on-screen top-left stays put.
void resize(String id, {required double scale, required Offset position}) {
final newState = state.copyWith();
final index = PlacedWidget.getIndexByID(id, newState.images);
if (index < 0) return;

newState.images[index].scale = ImageScalePolicy.clamp(scale);

newState.images[index]
..scale = ImageScalePolicy.clamp(scale)
..position = position;
state = newState;
}

Expand Down
13 changes: 9 additions & 4 deletions lib/providers/text_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,16 @@ class TextProvider extends Notifier<List<PlacedText>> {
state = newState;
}

void updateSize(int index, double size) {
final newState = [...state];
if (index < 0 || index >= newState.length) return;
/// Sets a text's width and position together: on defense a resize moves
/// the stored position so the box's on-screen top-left stays put.
void resize(String id, {required double size, required Offset position}) {
final index = PlacedWidget.getIndexByID(id, state);
if (index < 0) return;

newState[index].size = size;
final newState = [...state];
newState[index]
..size = size
..position = position;
state = newState;
}

Expand Down
15 changes: 14 additions & 1 deletion lib/widgets/draggable_widgets/canonical_positioned.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,28 @@ class CanonicalPositionedBox extends StatelessWidget {
required this.attackScreenPosition,
required this.isAttack,
required this.child,
this.pinnedScreenPosition,
});

final Offset attackScreenPosition;
final bool isAttack;
final Widget child;

/// While set, the box keeps this on-screen top-left whatever its size.
///
/// On defense the box mirrors attack, so it hangs from its bottom-right
/// corner and growing it would move its top and left edges. A resize pins
/// the top-left here so only the dragged edge moves.
final Offset? pinnedScreenPosition;

@override
Widget build(BuildContext context) {
return Positioned.fill(
child: CustomSingleChildLayout(
delegate: _CanonicalBoxPositionDelegate(
attackScreenPosition: attackScreenPosition,
isAttack: isAttack,
pinnedScreenPosition: pinnedScreenPosition,
),
child: child,
),
Expand All @@ -35,10 +44,12 @@ class _CanonicalBoxPositionDelegate extends SingleChildLayoutDelegate {
const _CanonicalBoxPositionDelegate({
required this.attackScreenPosition,
required this.isAttack,
required this.pinnedScreenPosition,
});

final Offset attackScreenPosition;
final bool isAttack;
final Offset? pinnedScreenPosition;

@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
Expand All @@ -47,6 +58,7 @@ class _CanonicalBoxPositionDelegate extends SingleChildLayoutDelegate {

@override
Offset getPositionForChild(Size size, Size childSize) {
if (pinnedScreenPosition case final pinned?) return pinned;
if (isAttack) return attackScreenPosition;
return Offset(
size.width - attackScreenPosition.dx - childSize.width,
Expand All @@ -57,6 +69,7 @@ class _CanonicalBoxPositionDelegate extends SingleChildLayoutDelegate {
@override
bool shouldRelayout(covariant _CanonicalBoxPositionDelegate oldDelegate) {
return attackScreenPosition != oldDelegate.attackScreenPosition ||
isAttack != oldDelegate.isAttack;
isAttack != oldDelegate.isAttack ||
pinnedScreenPosition != oldDelegate.pinnedScreenPosition;
}
}
233 changes: 161 additions & 72 deletions lib/widgets/draggable_widgets/image/placed_image_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:icarus/providers/image_provider.dart';
import 'package:icarus/providers/screen_zoom_provider.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/widgets/draggable_widgets/adjacent_page_copy_menu.dart';
import 'package:icarus/widgets/draggable_widgets/canonical_positioned.dart';
import 'package:icarus/widgets/draggable_widgets/image/image_widget.dart';
import 'package:icarus/widgets/draggable_widgets/image/scalable_widget.dart';
import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart';
Expand All @@ -19,29 +20,114 @@ import 'package:shadcn_ui/shadcn_ui.dart';
class PlacedImageBuilder extends StatefulWidget {
const PlacedImageBuilder({
required this.placedImage,
required this.isAttack,
required this.onDragEnd,
required this.scale,
super.key,
});

final double scale;
final PlacedImage placedImage;
final bool isAttack;
final Function(DraggableDetails details) onDragEnd;
@override
State<PlacedImageBuilder> createState() => _PlacedImageBuilderState();
}

class _PlacedImageBuilderState extends State<PlacedImageBuilder> {
final _boxKey = GlobalKey();
double? localScale; // Make localScale nullable to check if it's initialized
bool isPanning = false;
bool isDragging = false;
Offset? pinnedScreenPosition;

/// How far a mid-resize side switch moved the image away from the held
/// pointer, so the width keeps following the pointer's movement.
double pointerShift = 0;

@override
void initState() {
super.initState();
localScale ??= ImageScalePolicy.clamp(widget.scale);
}

/// A side switch mid-resize moves the pin to where the image now shows,
/// so the position stored on release is read on the side it was pinned on.
/// The image leaves the held pointer behind, so later drag updates measure
/// from where the left edge was.
@override
void didUpdateWidget(covariant PlacedImageBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
final pinned = pinnedScreenPosition;
final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?;
if (pinned == null ||
renderBox == null ||
oldWidget.isAttack == widget.isAttack) {
return;
}
final coordinateSystem = CoordinateSystem.instance;
final boxSize = renderBox.size.bottomRight(Offset.zero);
final nextPinned = coordinateSystem.screenPositionForSide(
attackScreenPosition: coordinateSystem.screenPositionFromSide(
sideScreenPosition: pinned,
reflectionOffset: boxSize,
isAttack: oldWidget.isAttack,
),
reflectionOffset: boxSize,
isAttack: widget.isAttack,
);
pointerShift += nextPinned.dx - pinned.dx;
pinnedScreenPosition = nextPinned;
}

/// Stores the new scale at once, so a page save right after release keeps
/// it, with the position that keeps the image's top-left where the resize
/// pinned it. On defense the image hangs from its bottom-right corner, so
/// that position moves with the size.
///
/// The last drag update may not be laid out yet, so the pin holds for one
/// more frame, then the position is stored again if it moved, unless a
/// page switch loaded another page's copy of the image, which keeps its id.
Future<void> _finishResize(WidgetRef ref) async {
final resizedImage = widget.placedImage;
final pinned = pinnedScreenPosition;
if (pinned != null) _storeResize(ref, resizedImage, pinned);
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;

final stillLoaded = ref
.read(placedImageProvider)
.images
.any((image) => identical(image, resizedImage));
if (pinned != null && stillLoaded) _storeResize(ref, resizedImage, pinned);
setState(() {
isPanning = false;
pinnedScreenPosition = null;
pointerShift = 0;
});
}

/// Writes [image]'s new scale and the position that puts its top-left at
/// [pinned] as laid out now, unless both are already stored.
void _storeResize(WidgetRef ref, PlacedImage image, Offset pinned) {
final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;

final coordinateSystem = CoordinateSystem.instance;
final position = coordinateSystem.screenToCoordinate(
coordinateSystem.screenPositionFromSide(
sideScreenPosition: pinned,
reflectionOffset: renderBox.size.bottomRight(Offset.zero),
isAttack: widget.isAttack,
),
);
if (image.scale == localScale && image.position == position) return;
ref
.read(placedImageProvider.notifier)
.resize(image.id, scale: localScale!, position: position);
ref.read(strategyProvider.notifier).setUnsaved();
}

@override
Widget build(BuildContext context) {
if (localScale == null) {
Expand All @@ -58,83 +144,86 @@ class _PlacedImageBuilderState extends State<PlacedImageBuilder> {
ref.read(placedImageProvider).images[index].scale);
}

return ImageScaleController(
isDragging: isDragging,
onPanUpdate: (details) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) return;

final topLeftGlobal = renderBox.localToGlobal(Offset.zero);
final screenZoom = ref.read(screenZoomProvider);
final widthInScreenPixels =
details.globalPosition.dx - topLeftGlobal.dx;
final widthInContentSpace = widthInScreenPixels / screenZoom;
final widthInWorldSpace =
CoordinateSystem.instance.screenWidthToWorld(widthInContentSpace);

setState(() {
isPanning = true;
localScale = ImageScalePolicy.clamp(widthInWorldSpace);
});
},
onPanEnd: (details) {
final index = PlacedWidget.getIndexByID(
widget.placedImage.id,
ref.read(placedImageProvider).images,
);
ref
.read(placedImageProvider.notifier)
.updateScale(index, localScale!);
ref.read(strategyProvider.notifier).setUnsaved();
final coordinateSystem = CoordinateSystem.instance;
final attackScreenPosition =
coordinateSystem.coordinateToScreen(widget.placedImage.position);
return CanonicalPositionedBox(
attackScreenPosition: attackScreenPosition,
isAttack: widget.isAttack,
pinnedScreenPosition: pinnedScreenPosition,
child: ImageScaleController(
key: _boxKey,
isDragging: isDragging,
onPanUpdate: (details) {
final renderBox =
_boxKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;

final topLeftGlobal = renderBox.localToGlobal(Offset.zero);
final screenZoom = ref.read(screenZoomProvider);
final widthInScreenPixels =
details.globalPosition.dx - topLeftGlobal.dx;
final widthInContentSpace =
widthInScreenPixels / screenZoom + pointerShift;
final widthInWorldSpace =
coordinateSystem.screenWidthToWorld(widthInContentSpace);

setState(() {
isPanning = false;
});
},
child: Draggable<PlacedWidget>(
data: widget.placedImage,
feedback: ZoomTransform(
child: IgnorePointer(
child: ImageWidget(
isFeedback: true,
link: widget.placedImage.link,
aspectRatio: widget.placedImage.aspectRatio,
scale: localScale!,
fileExtension: widget.placedImage.fileExtension,
id: widget.placedImage.id,
tagColorValue: widget.placedImage.tagColorValue,
),
),
),
childWhenDragging: const SizedBox.shrink(),
dragAnchorStrategy:
ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy,
onDragStarted: () {
setState(() {
isDragging = true;
});
},
onDragEnd: (details) {
widget.onDragEnd(details);
setState(() {
isDragging = false;
isPanning = true;
pinnedScreenPosition ??= coordinateSystem.screenPositionForSide(
attackScreenPosition: attackScreenPosition,
reflectionOffset: renderBox.size.bottomRight(Offset.zero),
isAttack: widget.isAttack,
);
localScale = ImageScalePolicy.clamp(widthInWorldSpace);
});
},
child: ShadContextMenuRegion(
items: _buildTagColorItems(ref),
child: MouseWatch(
cursor: SystemMouseCursors.click,
deleteTarget: HoveredDeleteTarget.image(
id: widget.placedImage.id,
ownerToken: Object(),
onPanEnd: (_) => _finishResize(ref),
child: Draggable<PlacedWidget>(
data: widget.placedImage,
feedback: ZoomTransform(
child: IgnorePointer(
child: ImageWidget(
isFeedback: true,
link: widget.placedImage.link,
aspectRatio: widget.placedImage.aspectRatio,
scale: localScale!,
fileExtension: widget.placedImage.fileExtension,
id: widget.placedImage.id,
tagColorValue: widget.placedImage.tagColorValue,
),
),
child: ImageWidget(
fileExtension: widget.placedImage.fileExtension,
aspectRatio: widget.placedImage.aspectRatio,
link: widget.placedImage.link,
scale: localScale!,
id: widget.placedImage.id,
tagColorValue: widget.placedImage.tagColorValue,
),
childWhenDragging: const SizedBox.shrink(),
dragAnchorStrategy:
ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy,
onDragStarted: () {
setState(() {
isDragging = true;
});
},
onDragEnd: (details) {
widget.onDragEnd(details);
setState(() {
isDragging = false;
});
},
child: ShadContextMenuRegion(
items: _buildTagColorItems(ref),
child: MouseWatch(
cursor: SystemMouseCursors.click,
deleteTarget: HoveredDeleteTarget.image(
id: widget.placedImage.id,
ownerToken: Object(),
),
child: ImageWidget(
fileExtension: widget.placedImage.fileExtension,
aspectRatio: widget.placedImage.aspectRatio,
link: widget.placedImage.link,
scale: localScale!,
id: widget.placedImage.id,
tagColorValue: widget.placedImage.tagColorValue,
),
),
),
),
Expand Down
Loading
Loading