Skip to content
Open
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
79 changes: 36 additions & 43 deletions apps/flites/lib/feature_kits/tools/canvas_helpers/image_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import '../../../main.dart';
import '../../../states/canvas_controller.dart';
import '../../../states/key_events.dart';
import '../../../states/open_project.dart';
import '../../../states/selected_image_state.dart';
import '../../../types/flites_image.dart';
import '../../../utils/bounding_box_utils.dart';
import '../../../utils/get_flite_image.dart';
Expand Down Expand Up @@ -38,55 +37,49 @@ class ImageEditor extends StatelessWidget {

@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
CanvasController.updateCanvasSize(constraints.biggest);
builder: (context, constraints) {
CanvasController.updateCanvasSize(constraints.biggest);

return Watch(
(context) {
final referenceImages =
getFliteImages(selectedReferenceImages.value);
return Watch((context) {
final referenceImages = getFliteImages(selectedReferenceImages.value);

// We're subscribing to the canvas scaling factor
// to ensure the reference images update correctly
// when the canvas is zoomed in or out.
appState.canvasScalingFactor.value;
// We're subscribing to the canvas scaling factor
// to ensure the reference images update correctly
// when the canvas is zoomed in or out.
appState.canvasScalingFactor.value;

return _CanvasGestureHandler(
child: Stack(
children: [
// Canvas Background
background(context),
return _CanvasGestureHandler(
child: Stack(
children: [
// Canvas Background
background(context),

// Bounding Box
const _CanvasBoundingBox(),
// Bounding Box
const _CanvasBoundingBox(),

// Reference Images
...referenceImages.map(
CanvasReferenceImage.new,
),
// Reference Images
...referenceImages.map(CanvasReferenceImage.new),

// Tool-specific canvases
child,
// Tool-specific canvases
child,

// Zoom Controls
if (showZoomControls)
const Positioned(
right: Sizes.p32,
bottom: Sizes.p32,
child: ZoomControls(),
),

// Additional widgets to be displayed on top of the canvas
...stackChildren,
],
// Zoom Controls
if (showZoomControls)
const Positioned(
right: Sizes.p32,
bottom: Sizes.p32,
child: ZoomControls(),
),
);
},
);
},
);

Widget background(BuildContext context) => Container(
decoration: BoxDecoration(color: context.colors.surface),
);
// Additional widgets to be displayed on top of the canvas
...stackChildren,
],
),
);
});
},
);

Widget background(BuildContext context) =>
Container(decoration: BoxDecoration(color: context.colors.surface));
}
49 changes: 20 additions & 29 deletions apps/flites/lib/feature_kits/tools/move_resize_tool.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_box_transform/flutter_box_transform.dart';
import 'package:signals/signals_flutter.dart';

import '../../main.dart';
import '../../states/canvas_controller.dart';
Expand All @@ -10,7 +9,6 @@ import '../../widgets/layout/app_shell.dart';
import '../../widgets/player/player.dart';
import 'canvas_helpers/image_editor.dart';
import 'canvas_helpers/selected_image_rect_wrapper.dart';
import 'rotate_tool.dart';

/// Displays the current selection in move/resize mode
class MoveResizeTool extends StatefulWidget {
Expand All @@ -32,35 +30,28 @@ class MoveResizeToolState extends State<MoveResizeTool> {
currentSelection,
selectedImageRect,
) =>
Watch(
(context) {
final rotationAngle = rotationSignal.value ?? 0;
return TransformableBox(
visibleHandles:
rotationAngle == 0 ? HandlePosition.corners.toSet() : {},
enabledHandles:
rotationAngle == 0 ? HandlePosition.corners.toSet() : {},
cornerHandleBuilder: (context, handle) => AngularHandle(
handle: handle,
length: 16,
color: context.colors.onSurface,
thickness: 3,
),
resizeModeResolver: () => ResizeMode.symmetricScale,
rect: selectedImageRect,
onChanged: (result, event) {
currentSelection.updatePositionAndSize(
result.rect,
CanvasController.canvasScalingFactor,
CanvasController.canvasPosition,
);

SourceFilesState.saveImageChanges(currentSelection);
},
contentBuilder: (context, rect, flip) =>
FlitesImageRenderer(flitesImage: currentSelection),
TransformableBox(
visibleHandles: HandlePosition.corners.toSet(),
enabledHandles: HandlePosition.corners.toSet(),
cornerHandleBuilder: (context, handle) => AngularHandle(
handle: handle,
length: 16,
color: context.colors.onSurface,
thickness: 3,
),
resizeModeResolver: () => ResizeMode.symmetricScale,
rect: selectedImageRect,
onChanged: (result, event) {
currentSelection.updatePositionAndSize(
result.rect,
CanvasController.canvasScalingFactor,
CanvasController.canvasPosition,
);

SourceFilesState.saveImageChanges(currentSelection);
},
contentBuilder: (context, rect, flip) =>
FlitesImageRenderer(flitesImage: currentSelection),
),
),
),
Expand Down
7 changes: 0 additions & 7 deletions apps/flites/lib/feature_kits/tools/rotate_tool.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';

import '../../states/canvas_controller.dart';
import '../../widgets/flites_image_renderer/flites_image_renderer.dart';
Expand All @@ -9,8 +8,6 @@ import '../../widgets/rotation/rotation_wrapper.dart';
import 'canvas_helpers/image_editor.dart';
import 'canvas_helpers/selected_image_rect_wrapper.dart';

final rotationSignal = signal<double?>(null);

/// Displays the current selection in rotate mode
class RotateTool extends StatelessWidget {
const RotateTool({super.key});
Expand Down Expand Up @@ -45,10 +42,6 @@ class RotateTool extends StatelessWidget {
CanvasController.canvasScalingFactor.toString(),
),
rect: selectedImageRect,
onRotate: (newAngle) {
currentSelection.rotation = newAngle;
},
initialRotation: currentSelection.rotation,
child: SizedBox(
width: selectedImageRect.width,
height: selectedImageRect.height,
Expand Down
34 changes: 30 additions & 4 deletions apps/flites/lib/types/flites_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class FlitesImage {
// Generate a unique ID for this image
id =
'${DateTime.now().millisecondsSinceEpoch}-${Random().nextInt(14000)}-${Random().nextInt(15000)}';
} on Exception {
} on Exception catch (e) {
// Rethrow with more context
throw Exception('Failed to create FlitesImage: $e');
}
Expand All @@ -106,23 +106,49 @@ class FlitesImage {
}

try {
// Remember the center and scale so the image stays in place
final oldRawSize = ImageUtils.sizeOfRawImage(image);
final scaleOnCanvas = widthOnCanvas / oldRawSize.width;
final oldCenter = Offset(
positionOnCanvas.dx + widthOnCanvas / 2,
positionOnCanvas.dy + heightOnCanvas / 2,
);

rotation = rotationInRadians;

// Apply rotation to the image data based on type
// Track the trim center offset to compensate for asymmetric cropping
double trimCenterOffsetX = 0;
double trimCenterOffsetY = 0;

if (SvgUtils.isSvg(image)) {
image = await SvgUtils.rotateAndTrimSvg(image, rotation);
} else {
image = await ImageProcessingUtils.rotateInIsolates(image, rotation);
final result =
await ImageProcessingUtils.rotateInIsolates(image, rotation);
image = result.imageBytes;
trimCenterOffsetX = result.trimCenterOffsetX;
trimCenterOffsetY = result.trimCenterOffsetY;
}

// Reset rotation after applying it to the image data
rotation = 0;

widthOnCanvas = ImageUtils.sizeOfRawImage(image).width;
// Preserve the user's scale factor
widthOnCanvas = ImageUtils.sizeOfRawImage(image).width * scaleOnCanvas;

// Recenter the image at the same point, compensating for the
// asymmetric trim offset so the image doesn't jump
positionOnCanvas = Offset(
oldCenter.dx - widthOnCanvas / 2 +
(trimCenterOffsetX * scaleOnCanvas),
oldCenter.dy - heightOnCanvas / 2 +
(trimCenterOffsetY * scaleOnCanvas),
);

// Save the changes
SourceFilesState.saveImageChanges(this);
} on Exception {
} on Exception catch (e) {
debugPrint('Error applying rotation: $e');
}
}
Expand Down
107 changes: 79 additions & 28 deletions apps/flites/lib/utils/image_processing_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,59 +5,110 @@ import 'package:image/image.dart' as img;

import '../widgets/overlays/loading_overlay.dart';

/// Result of a rotation operation, including the trim offset so the caller
/// can compensate for the asymmetric crop and keep the image centered.
class RotationResult {
RotationResult({
required this.imageBytes,
required this.trimCenterOffsetX,
required this.trimCenterOffsetY,
});

final Uint8List imageBytes;

/// How much the center shifted (in raw pixels) due to trimming.
final double trimCenterOffsetX;
final double trimCenterOffsetY;
}

/// A utility class for working with images.
/// Holds methods for rotating images.
class ImageProcessingUtils {
// Private constructor to prevent instantiation
ImageProcessingUtils._();

/// Rotates an image by the given angle in radians.
/// The computation of each step is done in an isolate.
static Future<Uint8List> rotateInIsolates(
static Future<RotationResult> rotateInIsolates(
Uint8List pngBytes,
double angleRadians,
) async =>
withLoadingOverlay(
() async => compute(_processRotation, {
'pngBytes': pngBytes,
'angleRadians': angleRadians,
}),
() async {
final result = await compute(_processRotationIsolate, {
'pngBytes': pngBytes,
'angleRadians': angleRadians,
});
return RotationResult(
imageBytes: result['imageBytes'] as Uint8List,
trimCenterOffsetX: result['trimCenterOffsetX'] as double,
trimCenterOffsetY: result['trimCenterOffsetY'] as double,
);
},
);

// Handles the actual image rotation processing
static Uint8List _processRotation(Map<String, dynamic> args) {
final pngBytes = args['pngBytes'] as Uint8List;
final angleRadians = args['angleRadians'] as double;
/// Isolate entry point — accepts and returns Maps because Dart isolates
/// can only pass primitive types across the boundary.
static Map<String, dynamic> _processRotationIsolate(
Map<String, dynamic> args,
) {
final result = processRotation(
args['pngBytes'] as Uint8List,
args['angleRadians'] as double,
);
return {
'imageBytes': result.imageBytes,
'trimCenterOffsetX': result.trimCenterOffsetX,
'trimCenterOffsetY': result.trimCenterOffsetY,
};
}

// 1. Decode image
/// Rotates a PNG image by [angleRadians], trims transparent borders,
/// and returns the result with trim center offsets.
static RotationResult processRotation(
Uint8List pngBytes,
double angleRadians,
) {
final originalImage = getImageFromBytes(pngBytes);
if (originalImage == null) {
throw Exception('Unable to decode PNG');
}

// 2. Calculate diagonal
final diagonal = sqrt(
pow(originalImage.width, 2) + pow(originalImage.height, 2),
).ceil();

// 3. Create canvas
final canvas = img.Image(
width: diagonal,
height: diagonal,
numChannels: 4,
);

// 4. Rotate image
// copyRotate produces a symmetric output centered on the original center
final rotatedImage = img.copyRotate(
originalImage,
angle: angleRadians * 180 / pi,
interpolation: img.Interpolation.cubic,
);

// 5. Composite images
final compositeImage = img.compositeImage(canvas, rotatedImage);
// Find the non-transparent bounding box
final trimRect = img.findTrim(rotatedImage);
final trimX = trimRect[0];
final trimY = trimRect[1];
final trimWidth = trimRect[2];
final trimHeight = trimRect[3];

// 6. Trim and encode
final trimmedImage = img.trim(compositeImage);
return Uint8List.fromList(img.encodePng(trimmedImage));
// Calculate how much the center shifted due to asymmetric trimming.
// copyRotate center = (width/2, height/2) = the original image center.
// Trim center = (trimX + trimWidth/2, trimY + trimHeight/2).
final trimCenterOffsetX =
(trimX + trimWidth / 2.0) - rotatedImage.width / 2.0;
final trimCenterOffsetY =
(trimY + trimHeight / 2.0) - rotatedImage.height / 2.0;

final trimmedImage = img.copyCrop(
rotatedImage,
x: trimX,
y: trimY,
width: trimWidth,
height: trimHeight,
);

return RotationResult(
imageBytes: Uint8List.fromList(img.encodePng(trimmedImage)),
trimCenterOffsetX: trimCenterOffsetX,
trimCenterOffsetY: trimCenterOffsetY,
);
}

static img.Image? getImageFromBytes(Uint8List bytes) {
Expand Down
Loading
Loading