diff --git a/apps/flites/lib/core/app_state.dart b/apps/flites/lib/core/app_state.dart index 667d5f6..8ef878e 100644 --- a/apps/flites/lib/core/app_state.dart +++ b/apps/flites/lib/core/app_state.dart @@ -21,9 +21,7 @@ class AppState { /// The main project data containing all image rows and project metadata final _projectData = signal( FlitesImageMap( - rows: [ - FlitesImageRow(images: [], name: 'Animation 1'), - ], + rows: [FlitesImageRow(images: [], name: 'Animation 1')], ), ); @@ -59,9 +57,8 @@ class AppState { String? get selectedImageId => _selectedImageId.value; /// Currently selected animation (computed from selected row) - ReadonlySignal get selectedAnimation => computed( - () => _projectData.value.rows[_selectedRowIndex.value], - ); + ReadonlySignal get selectedAnimation => + computed(() => _projectData.value.rows[_selectedRowIndex.value]); /// Sets the selected image row void setSelectedRow(int index) { @@ -108,6 +105,81 @@ class AppState { _canvasPosition.value += offset; } + void reAlignAllImages() { + final center = _centerOfAllImages(); + + if (center == null) { + return; + } + + for (final image in _getCurrentRow().images) { + final offsetFromCenter = image.center - center; + final newPosition = image.positionOnCanvas - offsetFromCenter; + + saveImageChanges(image.copyWith(positionOnCanvas: newPosition)); + } + + centerCanvasOnAllImages(); + } + + // Centers the canvas around a single image. + void centerCanvasOnImage(String imageId) { + final currentRow = _getCurrentRow(); + final index = currentRow.images.indexWhere((image) => image.id == imageId); + + if (index == -1) { + return; + } + final targetImage = currentRow.images[index]; + + _recenterCanvas(targetImage.center); + } + + // Centers the canvas on the average of the currently selected images. + void centerCanvasOnAllImages() { + final center = _centerOfAllImages(); + + if (center == null) { + return; + } + + _recenterCanvas(center); + } + + // The non-scaled center point of all images + Offset? _centerOfAllImages() { + // Calculate center of currently selected image row. + final centersOfRow = _getCurrentRow().images + .map((image) => image.center) + .toList(); + if (centersOfRow.isEmpty) { + return null; + } + + // The average pixel center of the images in the currently selected row + //(in our coordinate system). + final averageCenter = + centersOfRow.reduce((a, b) => a + b) / centersOfRow.length.toDouble(); + + return averageCenter; + } + + // Re-centers the canvas around a non-scaled center point. + void _recenterCanvas(Offset unscaledTargetCenter) { + // Apply scaling factor to the average center (which is absolute). + final scaledImageCenter = unscaledTargetCenter * _canvasScalingFactor.value; + + // The center of the canvas from a window size perspective. + // This changes with window resizes + final canvasPixelCenter = Offset( + _canvasSizePixel.value.width / 2, + _canvasSizePixel.value.height / 2, + ); + + // Update the position of the canvas to center the images in the window + _canvasPosition.value = canvasPixelCenter - scaledImageCenter; + } + /// Updates canvas size void updateCanvasSize(Size size) { if (size != _canvasSizePixel.value) { @@ -142,10 +214,7 @@ class AppState { /// Adds a new image row void addImageRow(String name) { final currentRows = _projectData.value.rows; - final newRows = [ - ...currentRows, - FlitesImageRow(images: [], name: name), - ]; + final newRows = [...currentRows, FlitesImageRow(images: [], name: name)]; _projectData.value = _projectData.value.copyWith(rows: newRows); } @@ -207,9 +276,7 @@ class AppState { final currentRow = _getCurrentRow(); final newRow = currentRow.copyWith( images: currentRow.images - .map( - (img) => img.id == image.id ? image : img, - ) + .map((img) => img.id == image.id ? image : img) .toList(), ); _updateCurrentRow(newRow); @@ -218,8 +285,9 @@ class AppState { /// Deletes an image void deleteImage(String imageId) { final currentRow = _getCurrentRow(); - final updatedImages = - currentRow.images.where((image) => image.id != imageId).toList(); + final updatedImages = currentRow.images + .where((image) => image.id != imageId) + .toList(); final updatedRow = currentRow.copyWith(images: updatedImages); _updateCurrentRow(updatedRow); @@ -303,14 +371,17 @@ class AppState { /// Renames images according to their order void renameImagesAccordingToOrder() { final currentRow = _getCurrentRow(); - final baseName = - currentRow.name.toLowerCase().replaceAll(RegExp(r'\s+'), '_'); + final baseName = currentRow.name.toLowerCase().replaceAll( + RegExp(r'\s+'), + '_', + ); final newImages = currentRow.images.asMap().entries.map((entry) { final index = entry.key; final originalImage = entry.value; - final fileExtension = originalImage.originalName?.endsWith('.svg') ?? false + final fileExtension = + originalImage.originalName?.endsWith('.svg') ?? false ? '.svg' : '.png'; diff --git a/apps/flites/lib/ui/panel/controls/panel_list_item.dart b/apps/flites/lib/ui/panel/controls/panel_list_item.dart index adfd72d..cbfa61b 100644 --- a/apps/flites/lib/ui/panel/controls/panel_list_item.dart +++ b/apps/flites/lib/ui/panel/controls/panel_list_item.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import '../../../constants/app_sizes.dart'; +import '../../../core/app_state.dart'; import '../../../types/flites_image.dart'; import '../../../types/secondary_click_context_data.dart'; import '../../../utils/svg_utils.dart'; @@ -32,7 +33,8 @@ class PanelListItem extends StatefulWidget { final List Function({ required bool isHovered, required bool isActive, - })? actionButtons; + })? + actionButtons; final bool isSelected; final VoidCallback? onTap; final bool hoverSelected; @@ -45,24 +47,23 @@ class PanelListItem extends StatefulWidget { bool? isSelected, VoidCallback? onTap, List Function({required bool isHovered, required bool isActive})? - actionButtons, + actionButtons, String? title, String? subtitle, FlitesImage? image, String? value, IconData? icon, - }) => - PanelListItem( - title: title ?? this.title, - subtitle: subtitle ?? this.subtitle, - image: image ?? this.image, - actionButtons: actionButtons ?? this.actionButtons, - isSelected: isSelected ?? this.isSelected, - onTap: onTap ?? this.onTap, - hoverSelected: hoverSelected ?? this.hoverSelected, - value: value ?? this.value, - icon: icon ?? this.icon, - ); + }) => PanelListItem( + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + image: image ?? this.image, + actionButtons: actionButtons ?? this.actionButtons, + isSelected: isSelected ?? this.isSelected, + onTap: onTap ?? this.onTap, + hoverSelected: hoverSelected ?? this.hoverSelected, + value: value ?? this.value, + icon: icon ?? this.icon, + ); } class _PanelListItemState extends State { @@ -73,8 +74,10 @@ class _PanelListItemState extends State { final theme = Theme.of(context); final actionButtonList = - widget.actionButtons - ?.call(isHovered: isHovered, isActive: widget.isSelected) ?? + widget.actionButtons?.call( + isHovered: isHovered, + isActive: widget.isSelected, + ) ?? []; return RightClickableItemWrapper( @@ -82,6 +85,15 @@ class _PanelListItemState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(borderRadiusMd), child: HoverBtn( + onDoubleTap: () { + final imageId = widget.image?.id; + + if (imageId == null) { + return; + } + + appState.centerCanvasOnImage(imageId); + }, hoverColor: theme.colorScheme.surfaceContainerLow, customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), diff --git a/apps/flites/lib/utils/flites_image_factory.dart b/apps/flites/lib/utils/flites_image_factory.dart index 07c1df0..73a4bd2 100644 --- a/apps/flites/lib/utils/flites_image_factory.dart +++ b/apps/flites/lib/utils/flites_image_factory.dart @@ -156,11 +156,13 @@ class FlitesImageFactory { // Extract image data // Sort images by frame number (for GIFs) images.sort((a, b) { - final numA = int.tryParse( + final numA = + int.tryParse( RegExp(r'frame_(\d+)').firstMatch(a.name ?? '')?.group(1) ?? '0', ) ?? 0; - final numB = int.tryParse( + final numB = + int.tryParse( RegExp(r'frame_(\d+)').firstMatch(b.name ?? '')?.group(1) ?? '0', ) ?? 0; @@ -171,10 +173,7 @@ class FlitesImageFactory { final result = images .map((img) { try { - return FlitesImage.scaled( - img.image!, - originalName: img.name, - ); + return FlitesImage.scaled(img.image!, originalName: img.name); } on Exception { // Skip images that fail to process return null; diff --git a/apps/flites/lib/utils/png_utils.dart b/apps/flites/lib/utils/png_utils.dart index 1948712..6471bcf 100644 --- a/apps/flites/lib/utils/png_utils.dart +++ b/apps/flites/lib/utils/png_utils.dart @@ -3,13 +3,10 @@ import 'package:flutter/services.dart'; class PngUtils { // Private constructor to prevent instantiation PngUtils._(); - static Size getSizeOfPng(ByteData bytes) => Size( - bytes.getUint32(16).toDouble(), - bytes.getUint32(20).toDouble(), - ); + static Size getSizeOfPng(ByteData bytes) => + Size(bytes.getUint32(16).toDouble(), bytes.getUint32(20).toDouble()); static bool isPng(Uint8List data) { - // PNG signature const List pngSignature = [ 0x89, 0x50, @@ -21,10 +18,18 @@ class PngUtils { 0x0A, ]; - // Check if data has enough length and starts with the PNG signature - return data.length >= 8 && - data - .sublist(0, 8) - .every((byte) => byte == pngSignature[data.indexOf(byte)]); + if (data.length < 8) { + return false; + } + + // Compare index-to-index directly + for (int i = 0; i < 8; i++) { + if (data[i] != pngSignature[i]) { + // TODO: we could show a toast here "Invalid PNG data." + return false; + } + } + + return true; } } diff --git a/apps/flites/lib/widgets/project_file_list/widgets/main_frame_list.dart b/apps/flites/lib/widgets/project_file_list/widgets/main_frame_list.dart index 345405e..2c430c8 100644 --- a/apps/flites/lib/widgets/project_file_list/widgets/main_frame_list.dart +++ b/apps/flites/lib/widgets/project_file_list/widgets/main_frame_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; import '../../../core/app_state.dart'; @@ -13,10 +14,7 @@ import '../../../ui/panel/controls/panel_list.dart'; import '../../../ui/panel/controls/panel_list_item.dart'; class MainFrameList extends StatefulWidget { - const MainFrameList({ - super.key, - this.scrollController, - }); + const MainFrameList({super.key, this.scrollController}); final ScrollController? scrollController; @override @@ -43,78 +41,81 @@ class _MainFrameListState extends State { @override Widget build(BuildContext context) => Watch( - (context) => PanelList( - label: context.l10n.frames, - items: _getItems( - context, - selectedReferenceImages.value, - appState.projectData, - ), - onItemTap: (id) { - SelectedImageState.selectedImage = id; - }, - trailingWidget: PanelListItem( - title: context.l10n.addImage, - icon: CupertinoIcons.add, - onTap: () { - SourceFilesState.addImages().then((_) { - final imagesInRow = appState - .projectData.rows[appState.selectedRowIndex.value].images; + (context) => PanelList( + label: context.l10n.frames, + items: _getItems( + context, + selectedReferenceImages.value, + appState.projectData, + ), + onItemTap: (id) { + SelectedImageState.selectedImage = id; + }, + trailingWidget: PanelListItem( + title: context.l10n.addImage, + icon: CupertinoIcons.add, + onTap: () { + SourceFilesState.addImages().then((_) { + final imagesInRow = appState + .projectData + .rows[appState.selectedRowIndex.value] + .images; - if (imagesInRow.isEmpty) { - return; - } + if (imagesInRow.isEmpty) { + return; + } - SelectedImageState.selectedImage = imagesInRow.last.id; - }); - }, - ), - selectedValues: (appState.selectedImageId != null) - ? [appState.selectedImageId!] - : null, - onReorder: SourceFilesState.reorderImages, - sectionLabelControls: [ - IconBtn( - icon: CupertinoIcons.eye_solid, - tooltip: context.l10n.toggleVisibility, - onPressed: () { - final selectedRowIndex = appState.selectedRowIndex.value; - final currentRow = - appState.projectData.rows[selectedRowIndex]; - if (currentRow.images.length <= - selectedReferenceImages.value.toSet().length) { - selectedReferenceImages.value = []; - } else { - selectedReferenceImages.value = - currentRow.images.map((e) => e.id).toList(); - } - }, - ), - ], - scrollController: _scrollController, + SelectedImageState.selectedImage = imagesInRow.last.id; + }); + }, + ), + selectedValues: (appState.selectedImageId != null) + ? [appState.selectedImageId!] + : null, + onReorder: SourceFilesState.reorderImages, + sectionLabelControls: [ + IconBtn( + icon: Icons.center_focus_strong, + tooltip: context.l10n.toggleVisibility, + onPressed: appState.reAlignAllImages, + ), + IconBtn( + icon: CupertinoIcons.eye_solid, + tooltip: context.l10n.toggleVisibility, + onPressed: () { + final selectedRowIndex = appState.selectedRowIndex.value; + final currentRow = appState.projectData.rows[selectedRowIndex]; + if (currentRow.images.length <= + selectedReferenceImages.value.toSet().length) { + selectedReferenceImages.value = []; + } else { + selectedReferenceImages.value = currentRow.images + .map((e) => e.id) + .toList(); + } + }, ), - ); + ], + scrollController: _scrollController, + ), + ); List _getItems( BuildContext context, List selectedReferenceImages, FlitesImageMap projectSourceFiles, - ) => - projectSourceFiles.rows[appState.selectedRowIndex.value].images - .map( - (frameItem) => PanelListItem( - key: Key('file-${frameItem.id}'), - title: frameItem.displayName ?? frameItem.originalName ?? '', - image: frameItem, - actionButtons: ({ - required isHovered, - required isActive, - }) => - _getActionButtons(context, frameItem, isHovered, isActive), - value: frameItem.id, - ), - ) - .toList(); + ) => projectSourceFiles.rows[appState.selectedRowIndex.value].images + .map( + (frameItem) => PanelListItem( + key: Key('file-${frameItem.id}'), + title: frameItem.displayName ?? frameItem.originalName ?? '', + image: frameItem, + actionButtons: ({required isHovered, required isActive}) => + _getActionButtons(context, frameItem, isHovered, isActive), + value: frameItem.id, + ), + ) + .toList(); List _getActionButtons( BuildContext context, @@ -122,8 +123,9 @@ class _MainFrameListState extends State { bool isHovered, bool isSelected, ) { - final isCurrentReferenceImage = - selectedReferenceImages.value.contains(frameItem.id); + final isCurrentReferenceImage = selectedReferenceImages.value.contains( + frameItem.id, + ); return [ // delete diff --git a/apps/flites/lib/widgets/tool_controls/zoom_controls.dart b/apps/flites/lib/widgets/tool_controls/zoom_controls.dart index 2d7b0d7..3a55e6d 100644 --- a/apps/flites/lib/widgets/tool_controls/zoom_controls.dart +++ b/apps/flites/lib/widgets/tool_controls/zoom_controls.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../constants/app_sizes.dart'; +import '../../core/app_state.dart'; import '../../main.dart'; import '../../states/canvas_controller.dart'; @@ -9,42 +10,52 @@ class ZoomControls extends StatelessWidget { @override Widget build(BuildContext context) => DecoratedBox( - // width: 64, - decoration: BoxDecoration( - color: context.colors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(Sizes.p32), + // width: 64, + decoration: BoxDecoration( + color: context.colors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(Sizes.p32), + ), + child: Column( + children: [ + IconButton( + tooltip: context.l10n.zoomIn, + onPressed: () { + CanvasController.updateCanvasScale( + isIncreasingSize: true, + zoomingWithButtons: true, + ); + }, + icon: Icon( + Icons.zoom_in, + size: Sizes.p24, + color: context.colors.onSurface, + ), ), - child: Column( - children: [ - IconButton( - tooltip: context.l10n.zoomIn, - onPressed: () { - CanvasController.updateCanvasScale( - isIncreasingSize: true, - zoomingWithButtons: true, - ); - }, - icon: Icon( - Icons.zoom_in, - size: Sizes.p24, - color: context.colors.onSurface, - ), - ), - IconButton( - tooltip: context.l10n.zoomOut, - onPressed: () { - CanvasController.updateCanvasScale( - isIncreasingSize: false, - zoomingWithButtons: true, - ); - }, - icon: Icon( - Icons.zoom_out, - size: Sizes.p24, - color: context.colors.onSurface, - ), - ), - ], + IconButton( + tooltip: context.l10n.zoomOut, + onPressed: () { + CanvasController.updateCanvasScale( + isIncreasingSize: false, + zoomingWithButtons: true, + ); + }, + icon: Icon( + Icons.zoom_out, + size: Sizes.p24, + color: context.colors.onSurface, + ), ), - ); + + IconButton( + tooltip: context.l10n.zoomOut, + onPressed: appState.centerCanvasOnAllImages, + icon: Icon( + Icons.center_focus_strong, + size: Sizes.p24, + color: context.colors.onSurface, + ), + ), + ], + ), + ); }