diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce3e5664..1dc272828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 12.0.8 +- **PERF**(paint-editor): Optimize freestyle path building by reducing redundant `moveTo` calls, eliminating intermediate list allocations, and using `distanceSquared` instead of `distance`. +- **PERF**(paint-editor): Skip `Opacity` widget wrapping when layer opacity is 1.0. +- **PERF**(paint-editor): Replace O(N×M) layer filtering in `done()` with Map-based O(1) lookup. +- **PERF**(main-editor): Replace O(N²) layer copy loop when closing paint editor with single deep-copy and incremental shallow snapshots. +- **PERF**(main-editor): Batch history entries without redundant `updateActiveItems()` calls via new `skipUpdateActiveItems` parameter on `addHistory()`. + ## 12.0.7 - **FEAT**(text-editor): Add `leadingDistribution` property to `TextEditorStyle` for configuring how extra line height is distributed. Use `TextLeadingDistribution.even` to vertically center text within rounded background rects at non-default line heights. Defaults to `proportional` for backward compatibility. diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index d48ad1133..cc702d0fe 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -1669,29 +1669,59 @@ class ProImageEditorState extends State if (result == null) return; + // Deep-copy active layers ONCE, then build history entries incrementally. + // This avoids the O(N²) cost of copyLayerList on every addLayer call. + final runningLayers = _layerCopyManager.copyLayerList(activeLayers); + final historyLimit = stateHistoryConfigs.stateHistoryLimit; + final enableScreenshotLimit = + imageGenerationConfigs.enableBackgroundGeneration; + String lastLayerId = ''; for (var i = 0; i < result.layers.length; i++) { final layer = result.layers[i]; - final oldIndex = activeLayers.indexWhere((el) => el.id == layer.id); + final oldIndex = runningLayers.indexWhere((el) => el.id == layer.id); final duplicatedLayer = _layerCopyManager.duplicateLayer( layer, offset: Offset.zero, ); lastLayerId = duplicatedLayer.id; - addLayer( - duplicatedLayer, - removeLayerIndex: oldIndex, - blockSelectLayer: true, - blockCaptureScreenshot: true, - autoCorrectZoomOffset: false, - autoCorrectZoomScale: false, + + if (oldIndex >= 0) { + runningLayers.removeAt(oldIndex); + } + runningLayers.add(duplicatedLayer); + + // Add individual history entry (for per-layer undo) with a shallow + // snapshot — the layer objects themselves are already independent copies. + stateManager.addHistory( + EditorStateHistory(layers: List.of(runningLayers)), + historyLimit: historyLimit, + enableScreenshotLimit: enableScreenshotLimit, + skipUpdateActiveItems: true, + ); + _controllers.screenshot.addEmptyScreenshot( + screenshots: stateManager.screenshots, ); } + for (Layer layer in result.removedLayers) { - removeLayer(layer, blockCaptureScreenshot: true); + final layerPos = runningLayers.indexWhere((el) => el.id == layer.id); + if (layerPos < 0) continue; + runningLayers.removeAt(layerPos); + stateManager.addHistory( + EditorStateHistory(layers: List.of(runningLayers)), + historyLimit: historyLimit, + enableScreenshotLimit: enableScreenshotLimit, + skipUpdateActiveItems: true, + ); + _controllers.screenshot.addEmptyScreenshot( + screenshots: stateManager.screenshots, + ); } + stateManager.updateActiveItems(); + if (lastLayerId.isNotEmpty) { _selectLayerAfterHeroIsDone(lastLayerId); } diff --git a/lib/features/main_editor/services/state_manager.dart b/lib/features/main_editor/services/state_manager.dart index fb6d3dd70..dff08dca1 100644 --- a/lib/features/main_editor/services/state_manager.dart +++ b/lib/features/main_editor/services/state_manager.dart @@ -282,12 +282,13 @@ class StateManager { EditorStateHistory history, { int historyLimit = 1000, bool enableScreenshotLimit = true, + bool skipUpdateActiveItems = false, }) { _cleanForwardChanges(); _stateHistory.add(history); historyPointer = _stateHistory.length - 1; setHistoryLimit(historyLimit, enableScreenshotLimit); - updateActiveItems(); + if (!skipUpdateActiveItems) updateActiveItems(); } /// Redoes the last undone change, moving the history pointer forward by one diff --git a/lib/features/paint_editor/models/path_builder/path_builder_freestyle.dart b/lib/features/paint_editor/models/path_builder/path_builder_freestyle.dart index 549ebd770..9272c559b 100644 --- a/lib/features/paint_editor/models/path_builder/path_builder_freestyle.dart +++ b/lib/features/paint_editor/models/path_builder/path_builder_freestyle.dart @@ -24,40 +24,46 @@ class PathBuilderFreestyle extends PathBuilderBase { item.mode == PaintMode.freeStyleArrowStartEnd; final double dotRadius = painter.strokeWidth / 2; + final int len = offsets.length; - final scaled = List.generate( - offsets.length, - (i) => offsets[i] == null - ? null - : Offset(offsets[i]!.dx * scale, offsets[i]!.dy * scale), - growable: false, - ); - - for (int i = 0; i < scaled.length - 1; i++) { - final a = scaled[i]; - final b = scaled[i + 1]; - - if (a != null && b != null) { - path - ..moveTo(a.dx, a.dy) - ..lineTo(b.dx, b.dy); - } else if (a != null && b == null) { - // Add tiny circle at the dot point - path.addOval(Rect.fromCircle(center: a, radius: dotRadius)); + // Build path with minimal moveTo calls by tracking continuous segments. + // Only emit moveTo at the start of each segment, then lineTo for the rest. + bool needsMoveTo = true; + + for (int i = 0; i < len; i++) { + final raw = offsets[i]; + if (raw == null) { + needsMoveTo = true; + continue; + } + + final double sx = raw.dx * scale; + final double sy = raw.dy * scale; + + if (needsMoveTo) { + // Isolated dot: point at end of list or followed by null + if (i + 1 >= len || offsets[i + 1] == null) { + path.addOval( + Rect.fromCircle(center: Offset(sx, sy), radius: dotRadius), + ); + } else { + path.moveTo(sx, sy); + needsMoveTo = false; + } + } else { + path.lineTo(sx, sy); } } // Add arrowheads if needed if (hasArrowStart || hasArrowEnd) { - // Scale arrow size based on strokeWidth for consistent proportions final strokeFactor = painter.strokeWidth / 2; - // Minimum distance for stable direction calculation - final minDistance = 20.0 * scale; + // Use squared distance to avoid sqrt in comparisons + final minDistanceSq = 400.0 * scale * scale; if (hasArrowStart) { final (startPoint, directionPoint) = _findPointsWithMinDistance( - scaled, - minDistance, + minDistanceSq, fromStart: true, ); if (startPoint != null && directionPoint != null) { @@ -67,8 +73,7 @@ class PathBuilderFreestyle extends PathBuilderBase { if (hasArrowEnd) { final (endPoint, directionPoint) = _findPointsWithMinDistance( - scaled, - minDistance, + minDistanceSq, fromStart: false, ); if (endPoint != null && directionPoint != null) { @@ -78,57 +83,54 @@ class PathBuilderFreestyle extends PathBuilderBase { } painter.strokeCap = StrokeCap.round; + painter.strokeJoin = StrokeJoin.round; return path; } /// Finds two points with a minimum distance for stable direction calculation. /// + /// Uses squared distance to avoid sqrt. Scales offsets inline to avoid + /// allocating a separate scaled list. /// If [fromStart] is true, searches from the beginning of the list. /// Returns a tuple of (anchor point, direction point). (Offset?, Offset?) _findPointsWithMinDistance( - List points, - double minDistance, { + double minDistanceSq, { required bool fromStart, }) { Offset? anchorPoint; Offset? directionPoint; + final points = offsets; if (fromStart) { - // Find first non-null point as anchor for (int i = 0; i < points.length; i++) { if (points[i] != null) { - anchorPoint = points[i]; - // Find a point with sufficient distance + anchorPoint = points[i]! * scale; for (int j = i + 1; j < points.length; j++) { if (points[j] != null) { - final distance = (points[j]! - anchorPoint!).distance; - if (distance >= minDistance) { - directionPoint = points[j]; + final scaled = points[j]! * scale; + if ((scaled - anchorPoint).distanceSquared >= minDistanceSq) { + directionPoint = scaled; break; } - // Keep updating to at least have the furthest point found - directionPoint = points[j]; + directionPoint = scaled; } } break; } } } else { - // Find last non-null point as anchor for (int i = points.length - 1; i >= 0; i--) { if (points[i] != null) { - anchorPoint = points[i]; - // Find a point with sufficient distance + anchorPoint = points[i]! * scale; for (int j = i - 1; j >= 0; j--) { if (points[j] != null) { - final distance = (points[j]! - anchorPoint!).distance; - if (distance >= minDistance) { - directionPoint = points[j]; + final scaled = points[j]! * scale; + if ((scaled - anchorPoint).distanceSquared >= minDistanceSq) { + directionPoint = scaled; break; } - // Keep updating to at least have the furthest point found - directionPoint = points[j]; + directionPoint = scaled; } } break; diff --git a/lib/features/paint_editor/paint_editor.dart b/lib/features/paint_editor/paint_editor.dart index ead3a0c1e..d00a43599 100644 --- a/lib/features/paint_editor/paint_editor.dart +++ b/lib/features/paint_editor/paint_editor.dart @@ -664,22 +664,23 @@ class PaintEditorState extends State }, onCloseWithValue: () { if (!canUndo) return Navigator.pop(context); - final scale = _layerStackTransformHelper.scale; - final originalLayers = (widget.initConfigs.layers ?? []) - .whereType() - .toList(); + // Build a map for O(1) lookup instead of O(N) indexWhere + final originalErasedMap = >{}; + for (final layer + in (widget.initConfigs.layers ?? []).whereType()) { + originalErasedMap[layer.id] = layer.item.erasedOffsets; + } + final newLayers = activeHistory.layers.whereType().where(( layer, ) { - return originalLayers.indexWhere( - (el) => - el.id == layer.id && - listEquals(el.item.erasedOffsets, layer.item.erasedOffsets), - ) < - 0; + final originalErased = originalErasedMap[layer.id]; + if (originalErased == null) return true; + return !listEquals(originalErased, layer.item.erasedOffsets); }); + final transformedLayers = newLayers.map((layer) { return layer ..offset *= scale diff --git a/lib/shared/widgets/layer/widgets/layer_widget_paint_item.dart b/lib/shared/widgets/layer/widgets/layer_widget_paint_item.dart index c0641bf60..a20ae7718 100644 --- a/lib/shared/widgets/layer/widgets/layer_widget_paint_item.dart +++ b/lib/shared/widgets/layer/widgets/layer_widget_paint_item.dart @@ -44,22 +44,23 @@ class LayerWidgetPaintItem extends StatelessWidget { @override Widget build(BuildContext context) { - return Opacity( - opacity: layer.opacity, - child: CustomPaint( - size: layer.size, - willChange: willChange, - isComplex: layer.item.mode.isFreeStyleMode, - painter: DrawPaintItem( - item: layer.item, - scale: layer.scale, - selected: isSelected, - enabledHitDetection: enableHitDetection, - onHitChanged: onHitChanged, - paintEditorConfigs: paintEditorConfigs, - ), + final child = CustomPaint( + size: layer.size, + willChange: willChange, + isComplex: layer.item.mode.isFreeStyleMode, + painter: DrawPaintItem( + item: layer.item, + scale: layer.scale, + selected: isSelected, + enabledHitDetection: enableHitDetection, + onHitChanged: onHitChanged, + paintEditorConfigs: paintEditorConfigs, ), ); + + if (layer.opacity >= 1.0) return child; + + return Opacity(opacity: layer.opacity, child: child); } @override diff --git a/pubspec.yaml b/pubspec.yaml index b195bf99c..597161ff4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: pro_image_editor description: "A Flutter image editor: Seamlessly enhance your images with user-friendly editing features." -version: 12.0.7 +version: 12.0.8 homepage: https://github.com/hm21/pro_image_editor/ repository: https://github.com/hm21/pro_image_editor/ documentation: https://github.com/hm21/pro_image_editor/