diff --git a/api/lib/butterfly_api.dart b/api/lib/butterfly_api.dart index d9dd0bb2b1bf..48559b6c05ca 100644 --- a/api/lib/butterfly_api.dart +++ b/api/lib/butterfly_api.dart @@ -3,7 +3,7 @@ /// More dartdocs go here. library butterfly_api; -export 'src/butterfly_models.dart'; -export 'src/butterfly_helpers.dart'; +export 'butterfly_models.dart'; +export 'butterfly_helpers.dart'; // TODO: Export any libraries intended for clients of this package. diff --git a/api/lib/butterfly_helpers.dart b/api/lib/butterfly_helpers.dart new file mode 100644 index 000000000000..3a6c03fab310 --- /dev/null +++ b/api/lib/butterfly_helpers.dart @@ -0,0 +1,3 @@ +export 'src/helpers/asset_helper.dart'; +export 'src/helpers/point_helper.dart'; +export 'src/helpers/search_helper.dart'; diff --git a/api/lib/butterfly_models.dart b/api/lib/butterfly_models.dart new file mode 100644 index 000000000000..aafbe6a44a7f --- /dev/null +++ b/api/lib/butterfly_models.dart @@ -0,0 +1,22 @@ +export 'src/converter/core.dart'; +export 'src/converter/legacy.dart'; +export 'src/converter/note.dart'; +export 'src/models/animation.dart'; +export 'src/models/archive.dart'; +export 'src/models/area.dart'; +export 'src/models/asset.dart'; +export 'src/models/background.dart'; +export 'src/models/colors.dart'; +export 'src/models/data.dart'; +export 'src/models/element.dart'; +export 'src/models/export.dart'; +export 'src/models/info.dart'; +export 'src/models/meta.dart'; +export 'src/models/pack.dart'; +export 'src/models/page.dart'; +export 'src/models/painter.dart'; +export 'src/models/palette.dart'; +export 'src/models/point.dart'; +export 'src/models/property.dart'; +export 'src/models/tool.dart'; +export 'src/models/waypoint.dart'; diff --git a/api/lib/src/butterfly_helpers.dart b/api/lib/src/butterfly_helpers.dart deleted file mode 100644 index 89cd07d52555..000000000000 --- a/api/lib/src/butterfly_helpers.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'helpers/asset_helper.dart'; -export 'helpers/point_helper.dart'; -export 'helpers/search_helper.dart'; diff --git a/api/lib/src/butterfly_models.dart b/api/lib/src/butterfly_models.dart deleted file mode 100644 index fb1ccb790a68..000000000000 --- a/api/lib/src/butterfly_models.dart +++ /dev/null @@ -1,22 +0,0 @@ -export 'converter/core.dart'; -export 'converter/legacy.dart'; -export 'converter/note.dart'; -export 'models/animation.dart'; -export 'models/archive.dart'; -export 'models/area.dart'; -export 'models/asset.dart'; -export 'models/background.dart'; -export 'models/colors.dart'; -export 'models/data.dart'; -export 'models/element.dart'; -export 'models/export.dart'; -export 'models/info.dart'; -export 'models/meta.dart'; -export 'models/pack.dart'; -export 'models/page.dart'; -export 'models/painter.dart'; -export 'models/palette.dart'; -export 'models/point.dart'; -export 'models/property.dart'; -export 'models/tool.dart'; -export 'models/waypoint.dart'; diff --git a/api/lib/src/models/asset.dart b/api/lib/src/models/asset.dart index 00dda5da8c95..715687487a34 100644 --- a/api/lib/src/models/asset.dart +++ b/api/lib/src/models/asset.dart @@ -1,10 +1,11 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:butterfly_api/src/butterfly_helpers.dart'; -import 'package:butterfly_api/src/models/data.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import '../helpers/asset_helper.dart'; +import 'data.dart'; + part 'asset.freezed.dart'; part 'asset.g.dart'; diff --git a/app/lib/bloc/document_bloc.dart b/app/lib/bloc/document_bloc.dart index 28d9ef5ee8b7..892e1e877de0 100644 --- a/app/lib/bloc/document_bloc.dart +++ b/app/lib/bloc/document_bloc.dart @@ -174,41 +174,95 @@ class DocumentBloc extends ReplayBloc { null); } }, transformer: sequential()); - on((event, emit) async { - if (state is DocumentLoadSuccess) { - final current = state as DocumentLoadSuccess; - if (!(current.embedding?.editable ?? true)) return; - if (event.elements.isEmpty || - !current.page.content - .any((element) => event.elements.contains(element))) return; - final page = current.page; - final renderers = current.renderers; - current.currentIndexCubit.unbake( - unbakedElements: renderers.where((element) { - final remaining = !event.elements.contains( - element.element, - ); - if (!remaining) element.dispose(); - return remaining; - }).toList(), - ); - final newPage = page.copyWith( - content: List.from(page.content) - ..removeWhere((element) => event.elements.contains(element))); - // Remove unused assets - final unusedAssets = {}; - event.elements.whereType().forEach((element) { - final uri = Uri.tryParse(element.source); - if (uri?.scheme == '' && !newPage.usesSource(element.source)) { - unusedAssets.add(element.source); + on((event, emit) async { + final current = state; + if (current is! DocumentLoadSuccess) return; + final renderers = await Future.wait(event.elements.map((e) async { + final renderer = Renderer.fromInstance(e); + await renderer.setup(current.data, current.assetService, current.page); + return renderer; + }).toList()); + var content = List.from(current.page.content); + final transform = current.transformCubit.state; + for (var renderer in renderers) { + final index = content.indexOf(renderer.element); + if (index == -1) { + content.add(renderer.element); + continue; + } + content.removeAt(index); + var newIndex = index; + if (event.arrangement == Arrangement.front) { + newIndex = content.length - 1; + } else if (event.arrangement == Arrangement.back) { + newIndex = 0; + } else { + final rect = renderer.rect; + if (rect != null) { + final hits = (await rayCastRect(rect, this, transform)) + .map((e) => e.element) + .toList(); + final hitIndex = hits.indexOf(renderer.element); + if (hitIndex != -1) { + if (event.arrangement == Arrangement.backward && hitIndex != 0) { + newIndex = content.indexOf(hits[hitIndex - 1]); + } else if (event.arrangement == Arrangement.forward && + hitIndex != hits.length - 1) { + newIndex = content.indexOf(hits[hitIndex + 1]) + 1; + } + } } - }); - for (var asset in unusedAssets) { - current.data.removeAsset(asset); } - - await _saveState(emit, current.copyWith(page: newPage), null); + if (newIndex >= 0) { + content.insert(newIndex, renderer.element); + } else { + content.add(renderer.element); + } } + final newPage = current.page.copyWith(content: content); + return _saveState( + emit, + current.copyWith( + page: newPage, + ), + null) + .whenComplete(() => current.currentIndexCubit + .loadElements(current.data, current.assetService, newPage)); + }); + on((event, emit) async { + final current = state; + if (current is! DocumentLoadSuccess) return; + if (!(current.embedding?.editable ?? true)) return; + if (event.elements.isEmpty || + !current.page.content + .any((element) => event.elements.contains(element))) return; + final page = current.page; + final renderers = current.renderers; + current.currentIndexCubit.unbake( + unbakedElements: renderers.where((element) { + final remaining = !event.elements.contains( + element.element, + ); + if (!remaining) element.dispose(); + return remaining; + }).toList(), + ); + final newPage = page.copyWith( + content: List.from(page.content) + ..removeWhere((element) => event.elements.contains(element))); + // Remove unused assets + final unusedAssets = {}; + event.elements.whereType().forEach((element) { + final uri = Uri.tryParse(element.source); + if (uri?.scheme == '' && !newPage.usesSource(element.source)) { + unusedAssets.add(element.source); + } + }); + for (var asset in unusedAssets) { + current.data.removeAsset(asset); + } + + await _saveState(emit, current.copyWith(page: newPage), null); }, transformer: sequential()); on((event, emit) async { if (state is DocumentLoadSuccess) { diff --git a/app/lib/bloc/document_event.dart b/app/lib/bloc/document_event.dart index 31f189b6bb96..837686ac7ba2 100644 --- a/app/lib/bloc/document_event.dart +++ b/app/lib/bloc/document_event.dart @@ -64,6 +64,17 @@ class ElementsRemoved extends DocumentEvent { List get props => [elements]; } +enum Arrangement { forward, backward, front, back } + +class ElementsArranged extends DocumentEvent { + final List elements; + final Arrangement arrangement; + + const ElementsArranged(this.elements, this.arrangement); + @override + List get props => [elements, arrangement]; +} + class DocumentDescriptorChanged extends DocumentEvent { final String? name, description; diff --git a/app/lib/cubits/current_index.dart b/app/lib/cubits/current_index.dart index 16dbd0291c40..1b8c488c733d 100644 --- a/app/lib/cubits/current_index.dart +++ b/app/lib/cubits/current_index.dart @@ -450,6 +450,12 @@ class CurrentIndexCubit extends Cubit { Future loadElements( NoteData document, AssetService assetService, DocumentPage page) async { + for (var e in state.cameraViewport.unbakedElements) { + e.dispose(); + } + for (var e in state.cameraViewport.bakedElements) { + e.dispose(); + } final renderers = page.content.map((e) => Renderer.fromInstance(e)).toList(); await Future.wait(renderers diff --git a/app/lib/dialogs/elements.dart b/app/lib/dialogs/elements.dart index 34e94c10b780..de9542d6a3f9 100644 --- a/app/lib/dialogs/elements.dart +++ b/app/lib/dialogs/elements.dart @@ -1,5 +1,6 @@ import 'package:butterfly/cubits/current_index.dart'; import 'package:butterfly/handlers/handler.dart'; +import 'package:butterfly/visualizer/event.dart'; import 'package:butterfly_api/butterfly_api.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -71,6 +72,22 @@ class ElementsDialog extends StatelessWidget { leadingIcon: const PhosphorIcon(PhosphorIconsLight.trash), child: Text(AppLocalizations.of(context).delete), ), + SubmenuButton( + leadingIcon: const Icon(PhosphorIconsLight.layout), + menuStyle: const MenuStyle(alignment: Alignment.centerRight), + menuChildren: Arrangement.values + .map((e) => MenuItemButton( + leadingIcon: Icon(e.icon(PhosphorIconsStyle.light)), + child: Text(e.getLocalizedName(context)), + onPressed: () { + Navigator.of(context).pop(true); + context.read().add(ElementsArranged( + renderers.map((r) => r.element).toList(), e)); + }, + )) + .toList(), + child: Text(AppLocalizations.of(context).arrange), + ), MenuItemButton( onPressed: () { Navigator.of(context).pop(true); diff --git a/app/lib/handlers/eraser.dart b/app/lib/handlers/eraser.dart index 8f5d5e2f5ec4..3836186a2cf3 100644 --- a/app/lib/handlers/eraser.dart +++ b/app/lib/handlers/eraser.dart @@ -40,7 +40,8 @@ class EraserHandler extends Handler { if (!_currentlyErasing) { _currentlyErasing = true; // Raycast - final ray = await rayCast(globalPos, context.buildContext, size); + final ray = await rayCast(globalPos, context.getDocumentBloc(), + context.getCameraTransform(), size); final newElements = ray .map((e) => e.element) .whereType() diff --git a/app/lib/handlers/hand.dart b/app/lib/handlers/hand.dart index 37277ed7eef0..78884f177cba 100644 --- a/app/lib/handlers/hand.dart +++ b/app/lib/handlers/hand.dart @@ -285,7 +285,8 @@ class HandHandler extends Handler { } final settings = context.getSettings(); final radius = settings.selectSensitivity / transform.size; - final hits = await rayCast(globalPos, context.buildContext, radius); + final hits = await rayCast(globalPos, context.getDocumentBloc(), + context.getCameraTransform(), radius); if (hits.isEmpty) { if (!context.isCtrlPressed) { _selected.clear(); @@ -336,7 +337,8 @@ class HandHandler extends Handler { return; } final position = context.getCameraTransform().localToGlobal(localPosition); - final hits = await rayCast(position, context.buildContext, 0.0); + final hits = await rayCast( + position, context.getDocumentBloc(), context.getCameraTransform(), 0.0); final hit = hits.firstOrNull; final rect = hit?.rect; if ((rect != null && !(getSelectionRect()?.contains(position) ?? false)) && @@ -470,7 +472,8 @@ class HandHandler extends Handler { if (!context.isCtrlPressed) { _selected.clear(); } - final hits = await rayCastRect(freeSelection, context.buildContext); + final hits = await rayCastRect(freeSelection, context.getDocumentBloc(), + context.getCameraTransform()); _selected.addAll(hits); context.refresh(); } diff --git a/app/lib/handlers/handler.dart b/app/lib/handlers/handler.dart index 4c0c04432a99..069bdb8cc00d 100644 --- a/app/lib/handlers/handler.dart +++ b/app/lib/handlers/handler.dart @@ -267,21 +267,22 @@ class _RayCastParams { Future>> rayCast( Offset globalPosition, - BuildContext context, + DocumentBloc bloc, + CameraTransform transform, double radius, ) async { return rayCastRect( Rect.fromCircle(center: globalPosition, radius: radius), - context, + bloc, + transform, ); } Future>> rayCastRect( Rect rect, - BuildContext context, + DocumentBloc bloc, + CameraTransform transform, ) async { - final bloc = context.read(); - final transform = context.read().state; final state = bloc.state; if (state is! DocumentLoadSuccess) return {}; final renderers = state.cameraViewport.visibleElements; diff --git a/app/lib/handlers/layer.dart b/app/lib/handlers/layer.dart index 1f137c5569be..dfb5dcdae7db 100644 --- a/app/lib/handlers/layer.dart +++ b/app/lib/handlers/layer.dart @@ -9,8 +9,11 @@ class LayerHandler extends Handler { final transform = context.getCameraTransform(); final state = context.getState(); if (state == null) return; - final hits = await rayCast(transform.localToGlobal(event.localPosition), - context.buildContext, data.strokeWidth / transform.size); + final hits = await rayCast( + transform.localToGlobal(event.localPosition), + context.getDocumentBloc(), + context.getCameraTransform(), + data.strokeWidth / transform.size); context.addDocumentEvent(ElementsLayerChanged( state.currentLayer, hits.map((e) => e.element).toList())); } diff --git a/app/lib/handlers/path_eraser.dart b/app/lib/handlers/path_eraser.dart index b176d8316f81..fe5d9d54a8bd 100644 --- a/app/lib/handlers/path_eraser.dart +++ b/app/lib/handlers/path_eraser.dart @@ -12,7 +12,8 @@ class PathEraserHandler extends Handler { _removeRunning = true; final hits = await rayCast( transform.localToGlobal(event.localPosition), - context.buildContext, + context.getDocumentBloc(), + context.getCameraTransform(), data.strokeWidth / transform.size, ); context diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index c2a90d353bad..bd0174090f09 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -501,5 +501,10 @@ "version": "Version", "repository": "Repository", "pages": "Pages", - "navigator": "Navigator" + "navigator": "Navigator", + "arrange": "Arrange", + "bringToFront": "Bring to front", + "sendToBack": "Send to back", + "bringForward": "Bring forward", + "sendBackward": "Send backward" } \ No newline at end of file diff --git a/app/lib/visualizer/event.dart b/app/lib/visualizer/event.dart new file mode 100644 index 000000000000..5dd175369ad4 --- /dev/null +++ b/app/lib/visualizer/event.dart @@ -0,0 +1,34 @@ +import 'package:butterfly/bloc/document_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:material_leap/material_leap.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +extension ArangementVisualizer on Arrangement { + String getLocalizedName(BuildContext context) { + final loc = AppLocalizations.of(context); + switch (this) { + case Arrangement.back: + return loc.sendToBack; + case Arrangement.front: + return loc.bringToFront; + case Arrangement.backward: + return loc.sendBackward; + case Arrangement.forward: + return loc.bringForward; + } + } + + IconGetter get icon { + switch (this) { + case Arrangement.back: + return PhosphorIcons.arrowDown; + case Arrangement.front: + return PhosphorIcons.arrowUp; + case Arrangement.backward: + return PhosphorIcons.arrowDownLeft; + case Arrangement.forward: + return PhosphorIcons.arrowUpRight; + } + } +}